1use crate::error::{Result, TdbError};
7use crate::store::{StoreParams, TdbStore};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DatabaseMetadata {
15 pub name: String,
17 pub location: PathBuf,
19 pub created_at: SystemTime,
21 pub modified_at: SystemTime,
23 pub version: String,
25 pub size_bytes: u64,
27 pub triple_count: u64,
29 pub status: DatabaseStatus,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum DatabaseStatus {
36 Active,
38 Creating,
40 Compacting,
42 BackingUp,
44 Repairing,
46 Offline,
48 Error,
50}
51
52pub struct DatabaseOps {
54 base_dir: PathBuf,
56}
57
58impl DatabaseOps {
59 pub fn new<P: AsRef<Path>>(base_dir: P) -> Result<Self> {
61 let base_dir = base_dir.as_ref().to_path_buf();
62
63 if !base_dir.exists() {
65 std::fs::create_dir_all(&base_dir)?;
66 }
67
68 Ok(Self { base_dir })
69 }
70
71 pub fn create_database(&self, name: &str, params: StoreParams) -> Result<DatabaseMetadata> {
73 let db_path = self.base_dir.join(name);
74
75 if db_path.exists() {
77 return Err(TdbError::InvalidInput(format!(
78 "Database '{}' already exists",
79 name
80 )));
81 }
82
83 self.validate_database_name(name)?;
85
86 std::fs::create_dir_all(&db_path)?;
88
89 let params_file = db_path.join("store_params.json");
91 params.save_to_file(¶ms_file)?;
92
93 let _store = TdbStore::open(&db_path)?;
95
96 let metadata = DatabaseMetadata {
98 name: name.to_string(),
99 location: db_path.clone(),
100 created_at: SystemTime::now(),
101 modified_at: SystemTime::now(),
102 version: crate::VERSION.to_string(),
103 size_bytes: self.calculate_database_size(&db_path)?,
104 triple_count: 0,
105 status: DatabaseStatus::Active,
106 };
107
108 self.save_metadata(&metadata)?;
110
111 log::info!("Created database '{}' at {:?}", name, db_path);
112
113 Ok(metadata)
114 }
115
116 pub fn delete_database(&self, name: &str) -> Result<()> {
118 let db_path = self.base_dir.join(name);
119
120 if !db_path.exists() {
121 return Err(TdbError::InvalidInput(format!(
122 "Database '{}' does not exist",
123 name
124 )));
125 }
126
127 std::fs::remove_dir_all(&db_path)?;
129
130 log::info!("Deleted database '{}'", name);
131
132 Ok(())
133 }
134
135 pub fn list_databases(&self) -> Result<Vec<DatabaseMetadata>> {
137 let mut databases = Vec::new();
138
139 if !self.base_dir.exists() {
140 return Ok(databases);
141 }
142
143 for entry in std::fs::read_dir(&self.base_dir)? {
144 let entry = entry?;
145 let path = entry.path();
146
147 if path.is_dir() {
148 if let Ok(metadata) = self.load_metadata(&path) {
150 databases.push(metadata);
151 }
152 }
153 }
154
155 Ok(databases)
156 }
157
158 pub fn get_metadata(&self, name: &str) -> Result<DatabaseMetadata> {
160 let db_path = self.base_dir.join(name);
161
162 if !db_path.exists() {
163 return Err(TdbError::InvalidInput(format!(
164 "Database '{}' does not exist",
165 name
166 )));
167 }
168
169 self.load_metadata(&db_path)
170 }
171
172 pub fn compact_database(&self, name: &str) -> Result<CompactionStats> {
174 let db_path = self.base_dir.join(name);
175
176 if !db_path.exists() {
177 return Err(TdbError::InvalidInput(format!(
178 "Database '{}' does not exist",
179 name
180 )));
181 }
182
183 let mut metadata = self.load_metadata(&db_path)?;
185 metadata.status = DatabaseStatus::Compacting;
186 self.save_metadata(&metadata)?;
187
188 let start_time = SystemTime::now();
189 let size_before = self.calculate_database_size(&db_path)?;
190
191 let mut store = TdbStore::open(&db_path)?;
193 store.compact()?;
194
195 let size_after = self.calculate_database_size(&db_path)?;
196 let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
197
198 metadata.status = DatabaseStatus::Active;
200 metadata.modified_at = SystemTime::now();
201 metadata.size_bytes = size_after;
202 self.save_metadata(&metadata)?;
203
204 let stats = CompactionStats {
205 size_before,
206 size_after,
207 space_saved: size_before.saturating_sub(size_after),
208 duration_secs: duration.as_secs_f64(),
209 compression_ratio: if size_before > 0 {
210 size_after as f64 / size_before as f64
211 } else {
212 1.0
213 },
214 };
215
216 log::info!(
217 "Compacted database '{}': saved {} bytes ({:.1}% reduction)",
218 name,
219 stats.space_saved,
220 (1.0 - stats.compression_ratio) * 100.0
221 );
222
223 Ok(stats)
224 }
225
226 pub fn repair_database(&self, name: &str) -> Result<RepairReport> {
228 let db_path = self.base_dir.join(name);
229
230 if !db_path.exists() {
231 return Err(TdbError::InvalidInput(format!(
232 "Database '{}' does not exist",
233 name
234 )));
235 }
236
237 let mut metadata = self.load_metadata(&db_path)?;
239 metadata.status = DatabaseStatus::Repairing;
240 self.save_metadata(&metadata)?;
241
242 let start_time = SystemTime::now();
243
244 let store = TdbStore::open(&db_path)?;
246 let diagnostic_report = store.run_diagnostics(crate::diagnostics::DiagnosticLevel::Deep);
247
248 let issues_found =
250 diagnostic_report.summary.error_count + diagnostic_report.summary.critical_count;
251 let issues_fixed = 0; let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
254
255 metadata.status = if issues_found == issues_fixed {
257 DatabaseStatus::Active
258 } else {
259 DatabaseStatus::Error
260 };
261 metadata.modified_at = SystemTime::now();
262 self.save_metadata(&metadata)?;
263
264 let report = RepairReport {
265 issues_found,
266 issues_fixed,
267 duration_secs: duration.as_secs_f64(),
268 success: issues_found == issues_fixed,
269 };
270
271 log::info!(
272 "Repaired database '{}': {} issues found, {} fixed",
273 name,
274 report.issues_found,
275 report.issues_fixed
276 );
277
278 Ok(report)
279 }
280
281 pub fn copy_database(&self, source: &str, destination: &str) -> Result<()> {
283 let source_path = self.base_dir.join(source);
284 let dest_path = self.base_dir.join(destination);
285
286 if !source_path.exists() {
287 return Err(TdbError::InvalidInput(format!(
288 "Source database '{}' does not exist",
289 source
290 )));
291 }
292
293 if dest_path.exists() {
294 return Err(TdbError::InvalidInput(format!(
295 "Destination database '{}' already exists",
296 destination
297 )));
298 }
299
300 self.validate_database_name(destination)?;
302
303 self.copy_dir_recursive(&source_path, &dest_path)?;
305
306 if let Ok(mut metadata) = self.load_metadata(&dest_path) {
308 metadata.name = destination.to_string();
309 metadata.location = dest_path.clone();
310 metadata.created_at = SystemTime::now();
311 self.save_metadata(&metadata)?;
312 }
313
314 log::info!("Copied database '{}' to '{}'", source, destination);
315
316 Ok(())
317 }
318
319 pub fn get_database_size(&self, name: &str) -> Result<u64> {
321 let db_path = self.base_dir.join(name);
322
323 if !db_path.exists() {
324 return Err(TdbError::InvalidInput(format!(
325 "Database '{}' does not exist",
326 name
327 )));
328 }
329
330 self.calculate_database_size(&db_path)
331 }
332
333 fn validate_database_name(&self, name: &str) -> Result<()> {
335 if name.is_empty() {
336 return Err(TdbError::InvalidInput(
337 "Database name cannot be empty".to_string(),
338 ));
339 }
340
341 if name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
343 return Err(TdbError::InvalidInput(format!(
344 "Database name '{}' contains invalid characters",
345 name
346 )));
347 }
348
349 Ok(())
350 }
351
352 #[allow(clippy::only_used_in_recursion)]
354 fn calculate_database_size(&self, path: &Path) -> Result<u64> {
355 let mut total_size = 0u64;
356
357 if path.is_dir() {
358 for entry in std::fs::read_dir(path)? {
359 let entry = entry?;
360 let entry_path = entry.path();
361
362 if entry_path.is_dir() {
363 total_size += self.calculate_database_size(&entry_path)?;
364 } else if entry_path.is_file() {
365 total_size += entry.metadata()?.len();
366 }
367 }
368 }
369
370 Ok(total_size)
371 }
372
373 fn save_metadata(&self, metadata: &DatabaseMetadata) -> Result<()> {
375 let metadata_file = metadata.location.join("metadata.json");
376 let json = serde_json::to_string_pretty(metadata)
377 .map_err(|e| TdbError::Serialization(format!("Failed to serialize metadata: {}", e)))?;
378 std::fs::write(metadata_file, json)?;
379 Ok(())
380 }
381
382 fn load_metadata(&self, db_path: &Path) -> Result<DatabaseMetadata> {
384 let metadata_file = db_path.join("metadata.json");
385
386 if !metadata_file.exists() {
387 let metadata = DatabaseMetadata {
389 name: db_path
390 .file_name()
391 .and_then(|n| n.to_str())
392 .unwrap_or("unknown")
393 .to_string(),
394 location: db_path.to_path_buf(),
395 created_at: SystemTime::now(),
396 modified_at: SystemTime::now(),
397 version: crate::VERSION.to_string(),
398 size_bytes: self.calculate_database_size(db_path)?,
399 triple_count: 0,
400 status: DatabaseStatus::Active,
401 };
402 self.save_metadata(&metadata)?;
403 return Ok(metadata);
404 }
405
406 let json = std::fs::read_to_string(metadata_file)?;
407 let metadata: DatabaseMetadata = serde_json::from_str(&json)
408 .map_err(|e| TdbError::Deserialization(format!("Failed to parse metadata: {}", e)))?;
409 Ok(metadata)
410 }
411
412 #[allow(clippy::only_used_in_recursion)]
414 fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> {
415 std::fs::create_dir_all(dst)?;
416
417 for entry in std::fs::read_dir(src)? {
418 let entry = entry?;
419 let src_path = entry.path();
420 let dst_path = dst.join(entry.file_name());
421
422 if src_path.is_dir() {
423 self.copy_dir_recursive(&src_path, &dst_path)?;
424 } else {
425 std::fs::copy(&src_path, &dst_path)?;
426 }
427 }
428
429 Ok(())
430 }
431}
432
433#[derive(Debug, Clone)]
435pub struct CompactionStats {
436 pub size_before: u64,
438 pub size_after: u64,
440 pub space_saved: u64,
442 pub duration_secs: f64,
444 pub compression_ratio: f64,
446}
447
448impl CompactionStats {
449 pub fn savings_percentage(&self) -> f64 {
451 if self.size_before > 0 {
452 (self.space_saved as f64 / self.size_before as f64) * 100.0
453 } else {
454 0.0
455 }
456 }
457}
458
459#[derive(Debug, Clone)]
461pub struct RepairReport {
462 pub issues_found: usize,
464 pub issues_fixed: usize,
466 pub duration_secs: f64,
468 pub success: bool,
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::store::StorePresets;
476 use std::env;
477
478 fn create_test_base_dir() -> PathBuf {
479 env::temp_dir().join(format!("oxirs_dbops_test_{}", uuid::Uuid::new_v4()))
480 }
481
482 #[test]
483 fn test_create_database() {
484 let base_dir = create_test_base_dir();
485 let ops = DatabaseOps::new(&base_dir).unwrap();
486
487 let params = StorePresets::minimal(base_dir.join("test_db"))
488 .build()
489 .unwrap();
490
491 let metadata = ops.create_database("test_db", params).unwrap();
492
493 assert_eq!(metadata.name, "test_db");
494 assert_eq!(metadata.status, DatabaseStatus::Active);
495 }
496
497 #[test]
498 fn test_list_databases() {
499 let base_dir = create_test_base_dir();
500 let ops = DatabaseOps::new(&base_dir).unwrap();
501
502 let params1 = StorePresets::minimal(base_dir.join("db1")).build().unwrap();
503 let params2 = StorePresets::minimal(base_dir.join("db2")).build().unwrap();
504
505 ops.create_database("db1", params1).unwrap();
506 ops.create_database("db2", params2).unwrap();
507
508 let databases = ops.list_databases().unwrap();
509 assert_eq!(databases.len(), 2);
510 }
511
512 #[test]
513 fn test_delete_database() {
514 let base_dir = create_test_base_dir();
515 let ops = DatabaseOps::new(&base_dir).unwrap();
516
517 let params = StorePresets::minimal(base_dir.join("test_db"))
518 .build()
519 .unwrap();
520
521 ops.create_database("test_db", params).unwrap();
522 assert!(ops.get_metadata("test_db").is_ok());
523
524 ops.delete_database("test_db").unwrap();
525 assert!(ops.get_metadata("test_db").is_err());
526 }
527
528 #[test]
529 fn test_get_database_size() {
530 let base_dir = create_test_base_dir();
531 let ops = DatabaseOps::new(&base_dir).unwrap();
532
533 let params = StorePresets::minimal(base_dir.join("test_db"))
534 .build()
535 .unwrap();
536
537 ops.create_database("test_db", params).unwrap();
538
539 let size = ops.get_database_size("test_db").unwrap();
540 assert!(size > 0);
541 }
542
543 #[test]
544 fn test_copy_database() {
545 let base_dir = create_test_base_dir();
546 let ops = DatabaseOps::new(&base_dir).unwrap();
547
548 let params = StorePresets::minimal(base_dir.join("source_db"))
549 .build()
550 .unwrap();
551
552 ops.create_database("source_db", params).unwrap();
553 ops.copy_database("source_db", "dest_db").unwrap();
554
555 assert!(ops.get_metadata("source_db").is_ok());
556 assert!(ops.get_metadata("dest_db").is_ok());
557 }
558
559 #[test]
560 fn test_validate_database_name() {
561 let base_dir = create_test_base_dir();
562 let ops = DatabaseOps::new(&base_dir).unwrap();
563
564 assert!(ops.validate_database_name("valid_name").is_ok());
565 assert!(ops.validate_database_name("").is_err());
566 assert!(ops.validate_database_name("invalid/name").is_err());
567 assert!(ops.validate_database_name("invalid:name").is_err());
568 }
569
570 #[test]
571 fn test_compaction_stats() {
572 let stats = CompactionStats {
573 size_before: 1000,
574 size_after: 600,
575 space_saved: 400,
576 duration_secs: 1.5,
577 compression_ratio: 0.6,
578 };
579
580 assert_eq!(stats.savings_percentage(), 40.0);
581 }
582
583 #[test]
584 fn test_repair_report() {
585 let report = RepairReport {
586 issues_found: 5,
587 issues_fixed: 5,
588 duration_secs: 2.0,
589 success: true,
590 };
591
592 assert!(report.success);
593 assert_eq!(report.issues_found, 5);
594 }
595}