use crate::error::{Result, TdbError};
use crate::store::{StoreParams, TdbStore};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseMetadata {
pub name: String,
pub location: PathBuf,
pub created_at: SystemTime,
pub modified_at: SystemTime,
pub version: String,
pub size_bytes: u64,
pub triple_count: u64,
pub status: DatabaseStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DatabaseStatus {
Active,
Creating,
Compacting,
BackingUp,
Repairing,
Offline,
Error,
}
pub struct DatabaseOps {
base_dir: PathBuf,
}
impl DatabaseOps {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Result<Self> {
let base_dir = base_dir.as_ref().to_path_buf();
if !base_dir.exists() {
std::fs::create_dir_all(&base_dir)?;
}
Ok(Self { base_dir })
}
pub fn create_database(&self, name: &str, params: StoreParams) -> Result<DatabaseMetadata> {
let db_path = self.base_dir.join(name);
if db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' already exists",
name
)));
}
self.validate_database_name(name)?;
std::fs::create_dir_all(&db_path)?;
let params_file = db_path.join("store_params.json");
params.save_to_file(¶ms_file)?;
let _store = TdbStore::open(&db_path)?;
let metadata = DatabaseMetadata {
name: name.to_string(),
location: db_path.clone(),
created_at: SystemTime::now(),
modified_at: SystemTime::now(),
version: crate::VERSION.to_string(),
size_bytes: self.calculate_database_size(&db_path)?,
triple_count: 0,
status: DatabaseStatus::Active,
};
self.save_metadata(&metadata)?;
log::info!("Created database '{}' at {:?}", name, db_path);
Ok(metadata)
}
pub fn delete_database(&self, name: &str) -> Result<()> {
let db_path = self.base_dir.join(name);
if !db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' does not exist",
name
)));
}
std::fs::remove_dir_all(&db_path)?;
log::info!("Deleted database '{}'", name);
Ok(())
}
pub fn list_databases(&self) -> Result<Vec<DatabaseMetadata>> {
let mut databases = Vec::new();
if !self.base_dir.exists() {
return Ok(databases);
}
for entry in std::fs::read_dir(&self.base_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Ok(metadata) = self.load_metadata(&path) {
databases.push(metadata);
}
}
}
Ok(databases)
}
pub fn get_metadata(&self, name: &str) -> Result<DatabaseMetadata> {
let db_path = self.base_dir.join(name);
if !db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' does not exist",
name
)));
}
self.load_metadata(&db_path)
}
pub fn compact_database(&self, name: &str) -> Result<CompactionStats> {
let db_path = self.base_dir.join(name);
if !db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' does not exist",
name
)));
}
let mut metadata = self.load_metadata(&db_path)?;
metadata.status = DatabaseStatus::Compacting;
self.save_metadata(&metadata)?;
let start_time = SystemTime::now();
let size_before = self.calculate_database_size(&db_path)?;
let mut store = TdbStore::open(&db_path)?;
store.compact()?;
let size_after = self.calculate_database_size(&db_path)?;
let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
metadata.status = DatabaseStatus::Active;
metadata.modified_at = SystemTime::now();
metadata.size_bytes = size_after;
self.save_metadata(&metadata)?;
let stats = CompactionStats {
size_before,
size_after,
space_saved: size_before.saturating_sub(size_after),
duration_secs: duration.as_secs_f64(),
compression_ratio: if size_before > 0 {
size_after as f64 / size_before as f64
} else {
1.0
},
};
log::info!(
"Compacted database '{}': saved {} bytes ({:.1}% reduction)",
name,
stats.space_saved,
(1.0 - stats.compression_ratio) * 100.0
);
Ok(stats)
}
pub fn repair_database(&self, name: &str) -> Result<RepairReport> {
let db_path = self.base_dir.join(name);
if !db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' does not exist",
name
)));
}
let mut metadata = self.load_metadata(&db_path)?;
metadata.status = DatabaseStatus::Repairing;
self.save_metadata(&metadata)?;
let start_time = SystemTime::now();
let store = TdbStore::open(&db_path)?;
let diagnostic_report = store.run_diagnostics(crate::diagnostics::DiagnosticLevel::Deep);
let issues_found =
diagnostic_report.summary.error_count + diagnostic_report.summary.critical_count;
let issues_fixed = 0;
let duration = start_time.elapsed().unwrap_or(Duration::from_secs(0));
metadata.status = if issues_found == issues_fixed {
DatabaseStatus::Active
} else {
DatabaseStatus::Error
};
metadata.modified_at = SystemTime::now();
self.save_metadata(&metadata)?;
let report = RepairReport {
issues_found,
issues_fixed,
duration_secs: duration.as_secs_f64(),
success: issues_found == issues_fixed,
};
log::info!(
"Repaired database '{}': {} issues found, {} fixed",
name,
report.issues_found,
report.issues_fixed
);
Ok(report)
}
pub fn copy_database(&self, source: &str, destination: &str) -> Result<()> {
let source_path = self.base_dir.join(source);
let dest_path = self.base_dir.join(destination);
if !source_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Source database '{}' does not exist",
source
)));
}
if dest_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Destination database '{}' already exists",
destination
)));
}
self.validate_database_name(destination)?;
self.copy_dir_recursive(&source_path, &dest_path)?;
if let Ok(mut metadata) = self.load_metadata(&dest_path) {
metadata.name = destination.to_string();
metadata.location = dest_path.clone();
metadata.created_at = SystemTime::now();
self.save_metadata(&metadata)?;
}
log::info!("Copied database '{}' to '{}'", source, destination);
Ok(())
}
pub fn get_database_size(&self, name: &str) -> Result<u64> {
let db_path = self.base_dir.join(name);
if !db_path.exists() {
return Err(TdbError::InvalidInput(format!(
"Database '{}' does not exist",
name
)));
}
self.calculate_database_size(&db_path)
}
fn validate_database_name(&self, name: &str) -> Result<()> {
if name.is_empty() {
return Err(TdbError::InvalidInput(
"Database name cannot be empty".to_string(),
));
}
if name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err(TdbError::InvalidInput(format!(
"Database name '{}' contains invalid characters",
name
)));
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn calculate_database_size(&self, path: &Path) -> Result<u64> {
let mut total_size = 0u64;
if path.is_dir() {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_dir() {
total_size += self.calculate_database_size(&entry_path)?;
} else if entry_path.is_file() {
total_size += entry.metadata()?.len();
}
}
}
Ok(total_size)
}
fn save_metadata(&self, metadata: &DatabaseMetadata) -> Result<()> {
let metadata_file = metadata.location.join("metadata.json");
let json = serde_json::to_string_pretty(metadata)
.map_err(|e| TdbError::Serialization(format!("Failed to serialize metadata: {}", e)))?;
std::fs::write(metadata_file, json)?;
Ok(())
}
fn load_metadata(&self, db_path: &Path) -> Result<DatabaseMetadata> {
let metadata_file = db_path.join("metadata.json");
if !metadata_file.exists() {
let metadata = DatabaseMetadata {
name: db_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string(),
location: db_path.to_path_buf(),
created_at: SystemTime::now(),
modified_at: SystemTime::now(),
version: crate::VERSION.to_string(),
size_bytes: self.calculate_database_size(db_path)?,
triple_count: 0,
status: DatabaseStatus::Active,
};
self.save_metadata(&metadata)?;
return Ok(metadata);
}
let json = std::fs::read_to_string(metadata_file)?;
let metadata: DatabaseMetadata = serde_json::from_str(&json)
.map_err(|e| TdbError::Deserialization(format!("Failed to parse metadata: {}", e)))?;
Ok(metadata)
}
#[allow(clippy::only_used_in_recursion)]
fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
self.copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CompactionStats {
pub size_before: u64,
pub size_after: u64,
pub space_saved: u64,
pub duration_secs: f64,
pub compression_ratio: f64,
}
impl CompactionStats {
pub fn savings_percentage(&self) -> f64 {
if self.size_before > 0 {
(self.space_saved as f64 / self.size_before as f64) * 100.0
} else {
0.0
}
}
}
#[derive(Debug, Clone)]
pub struct RepairReport {
pub issues_found: usize,
pub issues_fixed: usize,
pub duration_secs: f64,
pub success: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::StorePresets;
use std::env;
fn create_test_base_dir() -> PathBuf {
env::temp_dir().join(format!("oxirs_dbops_test_{}", uuid::Uuid::new_v4()))
}
#[test]
fn test_create_database() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
let params = StorePresets::minimal(base_dir.join("test_db"))
.build()
.unwrap();
let metadata = ops.create_database("test_db", params).unwrap();
assert_eq!(metadata.name, "test_db");
assert_eq!(metadata.status, DatabaseStatus::Active);
}
#[test]
fn test_list_databases() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
let params1 = StorePresets::minimal(base_dir.join("db1")).build().unwrap();
let params2 = StorePresets::minimal(base_dir.join("db2")).build().unwrap();
ops.create_database("db1", params1).unwrap();
ops.create_database("db2", params2).unwrap();
let databases = ops.list_databases().unwrap();
assert_eq!(databases.len(), 2);
}
#[test]
fn test_delete_database() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
let params = StorePresets::minimal(base_dir.join("test_db"))
.build()
.unwrap();
ops.create_database("test_db", params).unwrap();
assert!(ops.get_metadata("test_db").is_ok());
ops.delete_database("test_db").unwrap();
assert!(ops.get_metadata("test_db").is_err());
}
#[test]
fn test_get_database_size() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
let params = StorePresets::minimal(base_dir.join("test_db"))
.build()
.unwrap();
ops.create_database("test_db", params).unwrap();
let size = ops.get_database_size("test_db").unwrap();
assert!(size > 0);
}
#[test]
fn test_copy_database() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
let params = StorePresets::minimal(base_dir.join("source_db"))
.build()
.unwrap();
ops.create_database("source_db", params).unwrap();
ops.copy_database("source_db", "dest_db").unwrap();
assert!(ops.get_metadata("source_db").is_ok());
assert!(ops.get_metadata("dest_db").is_ok());
}
#[test]
fn test_validate_database_name() {
let base_dir = create_test_base_dir();
let ops = DatabaseOps::new(&base_dir).unwrap();
assert!(ops.validate_database_name("valid_name").is_ok());
assert!(ops.validate_database_name("").is_err());
assert!(ops.validate_database_name("invalid/name").is_err());
assert!(ops.validate_database_name("invalid:name").is_err());
}
#[test]
fn test_compaction_stats() {
let stats = CompactionStats {
size_before: 1000,
size_after: 600,
space_saved: 400,
duration_secs: 1.5,
compression_ratio: 0.6,
};
assert_eq!(stats.savings_percentage(), 40.0);
}
#[test]
fn test_repair_report() {
let report = RepairReport {
issues_found: 5,
issues_fixed: 5,
duration_secs: 2.0,
success: true,
};
assert!(report.success);
assert_eq!(report.issues_found, 5);
}
}