1use std::path::{Path, PathBuf};
6use std::fs;
7use chrono::{DateTime, Utc};
8use anyhow::{Result, Context};
9use serde::{Serialize, Deserialize};
10
11use crate::cards::{Card, CardConfig, CardCommand};
12use crate::storage::StorageManager;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BackupCardConfig {
17 pub backup_dir: PathBuf,
19
20 pub max_backups: usize,
22
23 pub auto_backup: bool,
25
26 pub backup_frequency: u32,
28
29 pub last_backup: Option<DateTime<Utc>>,
31}
32
33impl Default for BackupCardConfig {
34 fn default() -> Self {
35 Self {
36 backup_dir: dirs::data_dir()
37 .unwrap_or_else(|| PathBuf::from("."))
38 .join("pocket")
39 .join("backups"),
40 max_backups: 5,
41 auto_backup: true,
42 backup_frequency: 1,
43 last_backup: None,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BackupMetadata {
51 pub id: String,
53
54 pub created_at: DateTime<Utc>,
56
57 pub description: String,
59
60 pub snippet_count: usize,
62
63 pub repository_count: usize,
65
66 pub size: u64,
68}
69
70pub struct BackupCard {
72 name: String,
74
75 version: String,
77
78 description: String,
80
81 config: BackupCardConfig,
83
84 data_dir: PathBuf,
86}
87
88impl BackupCard {
89 pub fn new(data_dir: impl AsRef<Path>) -> Self {
91 Self {
92 name: "backup".to_string(),
93 version: env!("CARGO_PKG_VERSION").to_string(),
94 description: "Provides functionality for backing up and restoring snippets and repositories".to_string(),
95 config: BackupCardConfig::default(),
96 data_dir: data_dir.as_ref().to_path_buf(),
97 }
98 }
99
100 pub fn create_backup(&self, description: &str) -> Result<BackupMetadata> {
102 fs::create_dir_all(&self.config.backup_dir)
104 .context("Failed to create backup directory")?;
105
106 let backup_id = format!("backup_{}", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
108 let backup_dir = self.config.backup_dir.join(&backup_id);
109
110 fs::create_dir(&backup_dir)
112 .context("Failed to create backup directory")?;
113
114 self.copy_directory(&self.data_dir, &backup_dir)
116 .context("Failed to copy data directory")?;
117
118 let snippet_count = self.count_snippets(&backup_dir)?;
120 let repository_count = self.count_repositories(&backup_dir)?;
121
122 let size = self.directory_size(&backup_dir)?;
124
125 let metadata = BackupMetadata {
127 id: backup_id,
128 created_at: Utc::now(),
129 description: description.to_string(),
130 snippet_count,
131 repository_count,
132 size,
133 };
134
135 let metadata_path = backup_dir.join("metadata.json");
137 let metadata_json = serde_json::to_string_pretty(&metadata)?;
138 fs::write(&metadata_path, metadata_json)
139 .context("Failed to write backup metadata")?;
140
141 self.prune_old_backups()?;
143
144 Ok(metadata)
145 }
146
147 pub fn restore_backup(&self, backup_id: &str) -> Result<()> {
149 let backup_dir = self.config.backup_dir.join(backup_id);
150
151 if !backup_dir.exists() {
153 anyhow::bail!("Backup '{}' not found", backup_id);
154 }
155
156 let metadata_path = backup_dir.join("metadata.json");
158 if !metadata_path.exists() {
159 anyhow::bail!("Invalid backup: metadata.json not found");
160 }
161
162 let current_backup_id = format!("pre_restore_{}", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
164 let current_backup_dir = self.config.backup_dir.join(¤t_backup_id);
165
166 fs::create_dir(¤t_backup_dir)
168 .context("Failed to create backup directory for current state")?;
169
170 self.copy_directory(&self.data_dir, ¤t_backup_dir)
172 .context("Failed to backup current state")?;
173
174 self.clear_directory(&self.data_dir)
176 .context("Failed to clear data directory")?;
177
178 self.copy_directory(&backup_dir, &self.data_dir)
180 .context("Failed to restore backup")?;
181
182 Ok(())
183 }
184
185 pub fn list_backups(&self) -> Result<Vec<BackupMetadata>> {
187 if !self.config.backup_dir.exists() {
189 return Ok(Vec::new());
190 }
191
192 let mut backups = Vec::new();
193
194 for entry in fs::read_dir(&self.config.backup_dir)? {
196 let entry = entry?;
197 let path = entry.path();
198
199 if path.is_dir() {
201 let metadata_path = path.join("metadata.json");
203 if metadata_path.exists() {
204 let metadata_json = fs::read_to_string(&metadata_path)?;
206 let metadata: BackupMetadata = serde_json::from_str(&metadata_json)?;
207 backups.push(metadata);
208 }
209 }
210 }
211
212 backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
214
215 Ok(backups)
216 }
217
218 pub fn delete_backup(&self, backup_id: &str) -> Result<()> {
220 let backup_dir = self.config.backup_dir.join(backup_id);
221
222 if !backup_dir.exists() {
224 anyhow::bail!("Backup '{}' not found", backup_id);
225 }
226
227 fs::remove_dir_all(&backup_dir)
229 .context("Failed to delete backup")?;
230
231 Ok(())
232 }
233
234 fn prune_old_backups(&self) -> Result<()> {
236 let mut backups = self.list_backups()?;
238
239 if backups.len() <= self.config.max_backups {
241 return Ok(());
242 }
243
244 backups.sort_by(|a, b| a.created_at.cmp(&b.created_at));
246
247 for backup in backups.iter().take(backups.len() - self.config.max_backups) {
249 self.delete_backup(&backup.id)?;
250 }
251
252 Ok(())
253 }
254
255 fn copy_directory(&self, src: &Path, dst: &Path) -> Result<()> {
257 if !dst.exists() {
259 fs::create_dir_all(dst)?;
260 }
261
262 for entry in walkdir::WalkDir::new(src) {
264 let entry = entry?;
265 let src_path = entry.path();
266 let rel_path = src_path.strip_prefix(src)?;
267 let dst_path = dst.join(rel_path);
268
269 if src_path.is_dir() {
270 fs::create_dir_all(&dst_path)?;
272 } else {
273 fs::copy(src_path, &dst_path)?;
275 }
276 }
277
278 Ok(())
279 }
280
281 fn clear_directory(&self, dir: &Path) -> Result<()> {
283 if !dir.exists() {
285 return Ok(());
286 }
287
288 for entry in fs::read_dir(dir)? {
290 let entry = entry?;
291 let path = entry.path();
292
293 if path.is_dir() {
294 fs::remove_dir_all(&path)?;
296 } else {
297 fs::remove_file(&path)?;
299 }
300 }
301
302 Ok(())
303 }
304
305 fn count_snippets(&self, dir: &Path) -> Result<usize> {
307 let snippets_dir = dir.join("snippets");
308
309 if !snippets_dir.exists() {
310 return Ok(0);
311 }
312
313 let count = walkdir::WalkDir::new(&snippets_dir)
314 .min_depth(1)
315 .into_iter()
316 .filter_map(Result::ok)
317 .filter(|e| e.file_type().is_file() && e.path().extension().map_or(false, |ext| ext == "json"))
318 .count();
319
320 Ok(count)
321 }
322
323 fn count_repositories(&self, dir: &Path) -> Result<usize> {
325 let repos_dir = dir.join("repositories");
326
327 if !repos_dir.exists() {
328 return Ok(0);
329 }
330
331 let count = walkdir::WalkDir::new(&repos_dir)
332 .max_depth(1)
333 .min_depth(1)
334 .into_iter()
335 .filter_map(Result::ok)
336 .filter(|e| e.file_type().is_dir())
337 .count();
338
339 Ok(count)
340 }
341
342 fn directory_size(&self, dir: &Path) -> Result<u64> {
344 let mut size = 0;
345
346 for entry in walkdir::WalkDir::new(dir) {
347 let entry = entry?;
348 if entry.file_type().is_file() {
349 size += entry.metadata()?.len();
350 }
351 }
352
353 Ok(size)
354 }
355}
356
357impl Card for BackupCard {
358 fn name(&self) -> &str {
359 &self.name
360 }
361
362 fn version(&self) -> &str {
363 &self.version
364 }
365
366 fn description(&self) -> &str {
367 &self.description
368 }
369
370 fn initialize(&mut self, config: &CardConfig) -> Result<()> {
371 if let Some(backup_config) = config.options.get("backup") {
373 if let Ok(parsed_config) = serde_json::from_value::<BackupCardConfig>(backup_config.clone()) {
374 self.config = parsed_config;
375 }
376 }
377
378 fs::create_dir_all(&self.config.backup_dir)
380 .context("Failed to create backup directory")?;
381
382 Ok(())
383 }
384
385 fn execute(&self, command: &str, args: &[String]) -> Result<()> {
386 match command {
387 "backup" => {
388 let description = args.get(0).map(|s| s.as_str()).unwrap_or("Manual backup");
389 let metadata = self.create_backup(description)?;
390 println!("Backup created: {}", metadata.id);
391 println!("Description: {}", metadata.description);
392 println!("Created at: {}", metadata.created_at);
393 println!("Snippets: {}", metadata.snippet_count);
394 println!("Repositories: {}", metadata.repository_count);
395 println!("Size: {} bytes", metadata.size);
396 Ok(())
397 },
398 "restore" => {
399 if args.is_empty() {
400 anyhow::bail!("Backup ID is required");
401 }
402 let backup_id = &args[0];
403 self.restore_backup(backup_id)?;
404 println!("Backup '{}' restored successfully", backup_id);
405 Ok(())
406 },
407 "list" => {
408 let backups = self.list_backups()?;
409 if backups.is_empty() {
410 println!("No backups found");
411 } else {
412 println!("Available backups:");
413 for backup in backups {
414 println!("ID: {}", backup.id);
415 println!(" Description: {}", backup.description);
416 println!(" Created at: {}", backup.created_at);
417 println!(" Snippets: {}", backup.snippet_count);
418 println!(" Repositories: {}", backup.repository_count);
419 println!(" Size: {} bytes", backup.size);
420 println!();
421 }
422 }
423 Ok(())
424 },
425 "delete" => {
426 if args.is_empty() {
427 anyhow::bail!("Backup ID is required");
428 }
429 let backup_id = &args[0];
430 self.delete_backup(backup_id)?;
431 println!("Backup '{}' deleted successfully", backup_id);
432 Ok(())
433 },
434 _ => anyhow::bail!("Unknown command: {}", command),
435 }
436 }
437
438 fn commands(&self) -> Vec<CardCommand> {
439 vec![
440 CardCommand {
441 name: "backup".to_string(),
442 description: "Creates a backup of the current state".to_string(),
443 usage: "pocket backup [description]".to_string(),
444 },
445 CardCommand {
446 name: "restore".to_string(),
447 description: "Restores a backup".to_string(),
448 usage: "pocket restore <backup-id>".to_string(),
449 },
450 CardCommand {
451 name: "list".to_string(),
452 description: "Lists all available backups".to_string(),
453 usage: "pocket backup list".to_string(),
454 },
455 CardCommand {
456 name: "delete".to_string(),
457 description: "Deletes a backup".to_string(),
458 usage: "pocket backup delete <backup-id>".to_string(),
459 },
460 ]
461 }
462
463 fn cleanup(&mut self) -> Result<()> {
464 Ok(())
466 }
467}