1use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use chrono::Utc;
10use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
11use sqlx::Row;
12
13use super::DbPool;
14use crate::error::StorageError;
15
16#[derive(Debug, Clone)]
18pub struct BackupResult {
19 pub path: PathBuf,
21 pub size_bytes: u64,
23 pub duration_ms: u64,
25}
26
27#[derive(Debug, Clone)]
29pub struct BackupInfo {
30 pub path: PathBuf,
32 pub size_bytes: u64,
34 pub timestamp: Option<String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ValidationResult {
41 pub valid: bool,
43 pub tables: Vec<String>,
45 pub messages: Vec<String>,
47}
48
49pub async fn create_backup(pool: &DbPool, backup_dir: &Path) -> Result<BackupResult, StorageError> {
54 create_backup_with_prefix(pool, backup_dir, "tuitbot").await
55}
56
57async fn create_backup_with_prefix(
59 pool: &DbPool,
60 backup_dir: &Path,
61 prefix: &str,
62) -> Result<BackupResult, StorageError> {
63 std::fs::create_dir_all(backup_dir).map_err(|e| StorageError::Connection {
64 source: sqlx::Error::Configuration(
65 format!(
66 "failed to create backup directory {}: {e}",
67 backup_dir.display()
68 )
69 .into(),
70 ),
71 })?;
72
73 let timestamp = Utc::now().format("%Y%m%d_%H%M%S_%3f");
74 let filename = format!("{prefix}_{timestamp}.db");
75 let backup_path = backup_dir.join(&filename);
76
77 let start = Instant::now();
78
79 let path_str = backup_path
80 .to_str()
81 .ok_or_else(|| StorageError::Connection {
82 source: sqlx::Error::Configuration("backup path is not valid UTF-8".into()),
83 })?
84 .to_string();
85
86 let query = format!("VACUUM INTO '{}'", path_str.replace('\'', "''"));
88 sqlx::query(&query)
89 .execute(pool)
90 .await
91 .map_err(|e| StorageError::Query { source: e })?;
92
93 let duration_ms = start.elapsed().as_millis() as u64;
94
95 let metadata = std::fs::metadata(&backup_path).map_err(|e| StorageError::Connection {
96 source: sqlx::Error::Configuration(format!("failed to stat backup file: {e}").into()),
97 })?;
98
99 Ok(BackupResult {
100 path: backup_path,
101 size_bytes: metadata.len(),
102 duration_ms,
103 })
104}
105
106pub async fn validate_backup(backup_path: &Path) -> Result<ValidationResult, StorageError> {
108 if !backup_path.exists() {
109 return Ok(ValidationResult {
110 valid: false,
111 tables: vec![],
112 messages: vec![format!("File not found: {}", backup_path.display())],
113 });
114 }
115
116 match std::fs::read(backup_path) {
120 Ok(bytes) if bytes.len() < 16 => {
121 return Ok(ValidationResult {
122 valid: false,
123 tables: vec![],
124 messages: vec![format!(
125 "File is too small to be a SQLite database: {}",
126 backup_path.display()
127 )],
128 });
129 }
130 Ok(bytes) if !bytes.starts_with(b"SQLite format 3\0") => {
131 return Ok(ValidationResult {
132 valid: false,
133 tables: vec![],
134 messages: vec![format!(
135 "File is not a valid SQLite database: {}",
136 backup_path.display()
137 )],
138 });
139 }
140 Err(e) => {
141 return Ok(ValidationResult {
142 valid: false,
143 tables: vec![],
144 messages: vec![format!(
145 "Cannot read backup file {}: {}",
146 backup_path.display(),
147 e
148 )],
149 });
150 }
151 Ok(_) => {} }
153
154 let path_str = backup_path.to_string_lossy();
155 let options = SqliteConnectOptions::new()
156 .filename(&*path_str)
157 .read_only(true);
158
159 let pool = SqlitePoolOptions::new()
160 .max_connections(1)
161 .connect_with(options)
162 .await
163 .map_err(|e| StorageError::Connection { source: e })?;
164
165 let mut messages = Vec::new();
166
167 let rows = sqlx::query(
169 "SELECT name FROM sqlite_master WHERE type='table' \
170 AND name NOT LIKE 'sqlite_%' AND name != '_sqlx_migrations' \
171 ORDER BY name",
172 )
173 .fetch_all(&pool)
174 .await
175 .map_err(|e| StorageError::Query { source: e })?;
176
177 let tables: Vec<String> = rows.iter().map(|r| r.get("name")).collect();
178
179 let expected = [
181 "action_log",
182 "discovered_tweets",
183 "replies_sent",
184 "rate_limits",
185 ];
186 let mut missing = Vec::new();
187 for table in &expected {
188 if !tables.iter().any(|t| t == table) {
189 missing.push(*table);
190 }
191 }
192
193 let valid = missing.is_empty() && !tables.is_empty();
194
195 if valid {
196 messages.push(format!("Valid backup with {} tables", tables.len()));
197 } else if tables.is_empty() {
198 messages.push("No tables found in backup".to_string());
199 } else {
200 messages.push(format!("Missing expected tables: {}", missing.join(", ")));
201 }
202
203 let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
205 .fetch_one(&pool)
206 .await
207 .unwrap_or_else(|_| "error".to_string());
208
209 if integrity != "ok" {
210 messages.push(format!("Integrity check failed: {integrity}"));
211 return Ok(ValidationResult {
212 valid: false,
213 tables,
214 messages,
215 });
216 }
217
218 pool.close().await;
219
220 Ok(ValidationResult {
221 valid,
222 tables,
223 messages,
224 })
225}
226
227pub async fn restore_from_backup(
233 backup_path: &Path,
234 target_path: &Path,
235) -> Result<(), StorageError> {
236 let validation = validate_backup(backup_path).await?;
238 if !validation.valid {
239 return Err(StorageError::Connection {
240 source: sqlx::Error::Configuration(
241 format!(
242 "Backup validation failed: {}",
243 validation.messages.join("; ")
244 )
245 .into(),
246 ),
247 });
248 }
249
250 if target_path.exists() {
252 let parent = target_path.parent().unwrap_or_else(|| Path::new("."));
253 let safety_name = format!("pre_restore_{}.db", Utc::now().format("%Y%m%d_%H%M%S"));
254 let safety_path = parent.join(safety_name);
255 std::fs::copy(target_path, &safety_path).map_err(|e| StorageError::Connection {
256 source: sqlx::Error::Configuration(
257 format!("Failed to create safety backup: {e}").into(),
258 ),
259 })?;
260 tracing::info!(
261 path = %safety_path.display(),
262 "Created safety backup of current database"
263 );
264 }
265
266 let parent = target_path.parent().unwrap_or_else(|| Path::new("."));
268 let temp_path = parent.join(format!(
269 ".tuitbot_restore_{}.tmp",
270 Utc::now().timestamp_millis()
271 ));
272
273 std::fs::copy(backup_path, &temp_path).map_err(|e| StorageError::Connection {
274 source: sqlx::Error::Configuration(format!("Failed to copy backup: {e}").into()),
275 })?;
276
277 std::fs::rename(&temp_path, target_path).map_err(|e| StorageError::Connection {
278 source: sqlx::Error::Configuration(format!("Failed to rename temp to target: {e}").into()),
279 })?;
280
281 let wal_path = target_path.with_extension("db-wal");
283 let shm_path = target_path.with_extension("db-shm");
284 let _ = std::fs::remove_file(wal_path);
285 let _ = std::fs::remove_file(shm_path);
286
287 Ok(())
288}
289
290pub fn list_backups(backup_dir: &Path) -> Vec<BackupInfo> {
292 let mut backups = Vec::new();
293
294 let entries = match std::fs::read_dir(backup_dir) {
295 Ok(e) => e,
296 Err(_) => return backups,
297 };
298
299 for entry in entries.flatten() {
300 let path = entry.path();
301 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
302
303 if !name.starts_with("tuitbot_") || !name.ends_with(".db") {
304 continue;
305 }
306
307 let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
308
309 let timestamp = name
311 .strip_prefix("tuitbot_")
312 .and_then(|s| s.strip_suffix(".db"))
313 .map(|s| s.to_string());
314
315 backups.push(BackupInfo {
316 path,
317 size_bytes,
318 timestamp,
319 });
320 }
321
322 backups.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
324 backups
325}
326
327pub fn prune_backups(backup_dir: &Path, keep: usize) -> Result<u32, StorageError> {
331 let backups = list_backups(backup_dir);
332 let mut deleted = 0u32;
333
334 if backups.len() <= keep {
335 return Ok(0);
336 }
337
338 for backup in backups.iter().skip(keep) {
339 if let Err(e) = std::fs::remove_file(&backup.path) {
340 tracing::warn!(
341 path = %backup.path.display(),
342 error = %e,
343 "Failed to prune backup"
344 );
345 } else {
346 deleted += 1;
347 }
348 }
349
350 Ok(deleted)
351}
352
353pub async fn preflight_migration_backup(db_path: &Path) -> Result<Option<PathBuf>, StorageError> {
359 let metadata = match std::fs::metadata(db_path) {
361 Ok(m) if m.len() > 0 => m,
362 _ => return Ok(None),
363 };
364
365 tracing::info!(
366 db = %db_path.display(),
367 size_bytes = metadata.len(),
368 "Creating pre-migration backup"
369 );
370
371 let path_str = db_path.to_string_lossy();
373 let options = SqliteConnectOptions::new()
374 .filename(&*path_str)
375 .read_only(true);
376
377 let pool = SqlitePoolOptions::new()
378 .max_connections(1)
379 .connect_with(options)
380 .await
381 .map_err(|e| StorageError::Connection { source: e })?;
382
383 let backup_dir = db_path
384 .parent()
385 .unwrap_or_else(|| Path::new("."))
386 .join("backups");
387
388 let result = create_backup_with_prefix(&pool, &backup_dir, "pre_migration").await?;
389
390 pool.close().await;
391
392 tracing::info!(
393 path = %result.path.display(),
394 size_bytes = result.size_bytes,
395 duration_ms = result.duration_ms,
396 "Pre-migration backup complete"
397 );
398
399 prune_preflight_backups(&backup_dir, 3);
401
402 Ok(Some(result.path))
403}
404
405fn prune_preflight_backups(backup_dir: &Path, keep: usize) {
407 let entries = match std::fs::read_dir(backup_dir) {
408 Ok(e) => e,
409 Err(_) => return,
410 };
411
412 let mut pre_migration: Vec<PathBuf> = entries
413 .flatten()
414 .map(|e| e.path())
415 .filter(|p| {
416 p.file_name()
417 .and_then(|n| n.to_str())
418 .is_some_and(|n| n.starts_with("pre_migration_") && n.ends_with(".db"))
419 })
420 .collect();
421
422 pre_migration.sort_by(|a, b| b.cmp(a));
424
425 for path in pre_migration.iter().skip(keep) {
426 let _ = std::fs::remove_file(path);
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::storage::init_db;
434
435 async fn file_test_db(dir: &std::path::Path) -> (DbPool, PathBuf) {
437 let db_path = dir.join("test.db");
438 let pool = init_db(&db_path.to_string_lossy())
439 .await
440 .expect("init file db");
441 (pool, db_path)
442 }
443
444 #[tokio::test]
445 async fn create_and_validate_backup() {
446 let dir = tempfile::tempdir().expect("create temp dir");
447 let (pool, _db_path) = file_test_db(dir.path()).await;
448
449 sqlx::query(
451 "INSERT INTO action_log (action_type, status, message) \
452 VALUES ('test', 'success', 'backup test')",
453 )
454 .execute(&pool)
455 .await
456 .expect("insert");
457
458 let backup_dir = dir.path().join("backups");
459 let result = create_backup(&pool, &backup_dir).await.expect("backup");
460
461 assert!(result.path.exists());
462 assert!(result.size_bytes > 0);
463 assert!(result
464 .path
465 .file_name()
466 .unwrap()
467 .to_str()
468 .unwrap()
469 .starts_with("tuitbot_"));
470
471 let validation = validate_backup(&result.path).await.expect("validate");
473 assert!(validation.valid);
474 assert!(!validation.tables.is_empty());
475 assert!(validation.tables.contains(&"action_log".to_string()));
476
477 pool.close().await;
478 }
479
480 #[tokio::test]
481 async fn validate_nonexistent_file() {
482 let result = validate_backup(Path::new("/nonexistent/backup.db"))
483 .await
484 .expect("validate");
485 assert!(!result.valid);
486 }
487
488 #[tokio::test]
489 async fn list_and_prune_backups() {
490 let dir = tempfile::tempdir().expect("create temp dir");
491
492 for i in 1..=5 {
494 let name = format!("tuitbot_20240101_00000{i}.db");
495 std::fs::write(dir.path().join(name), "fake").expect("write");
496 }
497
498 let backups = list_backups(dir.path());
499 assert_eq!(backups.len(), 5);
500 assert!(
502 backups[0].timestamp.as_deref().unwrap() > backups[4].timestamp.as_deref().unwrap()
503 );
504
505 let pruned = prune_backups(dir.path(), 2).expect("prune");
507 assert_eq!(pruned, 3);
508
509 let remaining = list_backups(dir.path());
510 assert_eq!(remaining.len(), 2);
511 }
512
513 #[tokio::test]
514 async fn backup_and_restore() {
515 let dir = tempfile::tempdir().expect("create temp dir");
516 let (pool, _db_path) = file_test_db(dir.path()).await;
517
518 sqlx::query(
519 "INSERT INTO action_log (action_type, status, message) \
520 VALUES ('test', 'success', 'restore test')",
521 )
522 .execute(&pool)
523 .await
524 .expect("insert");
525
526 let backup_dir = dir.path().join("backups");
527 let result = create_backup(&pool, &backup_dir).await.expect("backup");
528 pool.close().await;
529
530 let target = dir.path().join("restored.db");
532
533 restore_from_backup(&result.path, &target)
534 .await
535 .expect("restore");
536
537 assert!(target.exists());
538
539 let options = SqliteConnectOptions::new()
541 .filename(target.to_string_lossy().as_ref())
542 .read_only(true);
543 let restored_pool = SqlitePoolOptions::new()
544 .max_connections(1)
545 .connect_with(options)
546 .await
547 .expect("open restored");
548
549 let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM action_log")
550 .fetch_one(&restored_pool)
551 .await
552 .expect("count");
553 assert_eq!(count.0, 1);
554 restored_pool.close().await;
555 }
556
557 #[tokio::test]
560 async fn validate_backup_too_small_file() {
561 let dir = tempfile::tempdir().expect("create temp dir");
562 let tiny_file = dir.path().join("tiny.db");
563 std::fs::write(&tiny_file, b"small").expect("write tiny");
564
565 let result = validate_backup(&tiny_file).await.expect("validate");
566 assert!(!result.valid);
567 assert!(result.messages[0].contains("too small"));
568 }
569
570 #[tokio::test]
571 async fn validate_backup_not_sqlite() {
572 let dir = tempfile::tempdir().expect("create temp dir");
573 let fake_file = dir.path().join("fake.db");
574 std::fs::write(&fake_file, b"This is not a SQLite database at all!").expect("write fake");
576
577 let result = validate_backup(&fake_file).await.expect("validate");
578 assert!(!result.valid);
579 assert!(result.messages[0].contains("not a valid SQLite"));
580 }
581
582 #[tokio::test]
583 async fn restore_from_invalid_backup_fails() {
584 let dir = tempfile::tempdir().expect("create temp dir");
585 let bad_backup = dir.path().join("bad.db");
586 std::fs::write(&bad_backup, b"not a database").expect("write bad");
587
588 let target = dir.path().join("target.db");
589 let err = restore_from_backup(&bad_backup, &target).await;
590 assert!(err.is_err(), "restore from invalid backup should fail");
591 }
592
593 #[tokio::test]
594 #[cfg_attr(
595 target_os = "windows",
596 ignore = "SQLite WAL lock prevents rename on Windows CI"
597 )]
598 async fn restore_over_existing_db_creates_safety_backup() {
599 let dir = tempfile::tempdir().expect("create temp dir");
600 let (pool, db_path) = file_test_db(dir.path()).await;
601
602 sqlx::query(
604 "INSERT INTO action_log (action_type, status, message) \
605 VALUES ('original', 'success', 'original data')",
606 )
607 .execute(&pool)
608 .await
609 .expect("insert");
610
611 let backup_dir = dir.path().join("backups");
613 let result = create_backup(&pool, &backup_dir).await.expect("backup");
614 pool.close().await;
616 drop(pool);
617
618 restore_from_backup(&result.path, &db_path)
620 .await
621 .expect("restore");
622
623 let entries: Vec<_> = std::fs::read_dir(dir.path())
625 .unwrap()
626 .flatten()
627 .filter(|e| {
628 e.file_name()
629 .to_str()
630 .is_some_and(|n| n.starts_with("pre_restore_"))
631 })
632 .collect();
633 assert!(
634 !entries.is_empty(),
635 "safety backup should be created when restoring over existing DB"
636 );
637 }
638
639 #[test]
640 fn list_backups_empty_dir() {
641 let dir = tempfile::tempdir().expect("create temp dir");
642 let backups = list_backups(dir.path());
643 assert!(backups.is_empty());
644 }
645
646 #[test]
647 fn list_backups_ignores_non_tuitbot_files() {
648 let dir = tempfile::tempdir().expect("create temp dir");
649 std::fs::write(dir.path().join("random_file.db"), "data").expect("write");
650 std::fs::write(dir.path().join("pre_migration_123.db"), "data").expect("write");
651 std::fs::write(dir.path().join("tuitbot_20240101_000001.txt"), "data").expect("write");
652
653 let backups = list_backups(dir.path());
654 assert!(backups.is_empty(), "should skip non-tuitbot_ .db files");
655 }
656
657 #[test]
658 fn list_backups_nonexistent_dir() {
659 let backups = list_backups(Path::new("/nonexistent/directory/xyz"));
660 assert!(backups.is_empty());
661 }
662
663 #[test]
664 fn prune_backups_when_fewer_than_keep() {
665 let dir = tempfile::tempdir().expect("create temp dir");
666 std::fs::write(dir.path().join("tuitbot_20240101_000001.db"), "data").expect("write");
667
668 let pruned = prune_backups(dir.path(), 5).expect("prune");
669 assert_eq!(pruned, 0, "nothing should be pruned when fewer than keep");
670 }
671
672 #[test]
673 fn prune_backups_exact_count() {
674 let dir = tempfile::tempdir().expect("create temp dir");
675 for i in 1..=3 {
676 let name = format!("tuitbot_20240101_00000{i}.db");
677 std::fs::write(dir.path().join(name), "data").expect("write");
678 }
679
680 let pruned = prune_backups(dir.path(), 3).expect("prune");
681 assert_eq!(pruned, 0, "nothing should be pruned when count == keep");
682 }
683
684 #[tokio::test]
685 async fn create_backup_duration_is_positive() {
686 let dir = tempfile::tempdir().expect("create temp dir");
687 let (pool, _db_path) = file_test_db(dir.path()).await;
688
689 let backup_dir = dir.path().join("backups");
690 let result = create_backup(&pool, &backup_dir).await.expect("backup");
691 assert!(result.duration_ms < 60_000, "backup should be fast");
693 pool.close().await;
694 }
695
696 #[tokio::test]
697 async fn backup_result_has_tuitbot_prefix() {
698 let dir = tempfile::tempdir().expect("create temp dir");
699 let (pool, _db_path) = file_test_db(dir.path()).await;
700
701 let backup_dir = dir.path().join("backups");
702 let result = create_backup(&pool, &backup_dir).await.expect("backup");
703
704 let filename = result
705 .path
706 .file_name()
707 .unwrap()
708 .to_str()
709 .unwrap()
710 .to_string();
711 assert!(filename.starts_with("tuitbot_"));
712 assert!(filename.ends_with(".db"));
713
714 pool.close().await;
715 }
716
717 #[tokio::test]
718 async fn preflight_migration_backup_skips_nonexistent_db() {
719 let dir = tempfile::tempdir().expect("create temp dir");
720 let fake_db = dir.path().join("nonexistent.db");
721
722 let result = preflight_migration_backup(&fake_db)
723 .await
724 .expect("preflight");
725 assert!(result.is_none(), "should skip nonexistent DB");
726 }
727
728 #[tokio::test]
729 async fn preflight_migration_backup_creates_backup() {
730 let dir = tempfile::tempdir().expect("create temp dir");
731 let (pool, db_path) = file_test_db(dir.path()).await;
732
733 sqlx::query(
735 "INSERT INTO action_log (action_type, status, message) \
736 VALUES ('test', 'success', 'preflight test')",
737 )
738 .execute(&pool)
739 .await
740 .expect("insert");
741 pool.close().await;
742
743 let result = preflight_migration_backup(&db_path)
744 .await
745 .expect("preflight");
746 assert!(result.is_some(), "should create backup for existing DB");
747 let backup_path = result.unwrap();
748 assert!(backup_path.exists());
749 assert!(backup_path
750 .file_name()
751 .unwrap()
752 .to_str()
753 .unwrap()
754 .starts_with("pre_migration_"));
755 }
756
757 #[tokio::test]
758 async fn validate_backup_with_valid_db() {
759 let dir = tempfile::tempdir().expect("create temp dir");
760 let (pool, _db_path) = file_test_db(dir.path()).await;
761
762 let backup_dir = dir.path().join("backups");
764 let result = create_backup(&pool, &backup_dir).await.expect("backup");
765 pool.close().await;
766
767 let validation = validate_backup(&result.path).await.expect("validate");
769 assert!(
770 validation.valid,
771 "valid backup should pass: {:?}",
772 validation.messages
773 );
774 assert!(validation.tables.contains(&"action_log".to_string()));
775 assert!(validation.tables.contains(&"rate_limits".to_string()));
776 assert!(
777 validation
778 .messages
779 .iter()
780 .any(|m| m.contains("Valid backup")),
781 "should report valid: {:?}",
782 validation.messages
783 );
784 }
785}