use crate::config::Config;
use crate::error::DevbrainError;
use crate::models::Entry;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs::{self, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
pub const DB_VERSION: &str = "1.0";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Database {
#[serde(default = "default_db_version")]
pub version: String,
pub entries: Vec<Entry>,
}
fn default_db_version() -> String {
DB_VERSION.to_string()
}
pub fn init_db(config: &Config) -> Result<(), DevbrainError> {
let db_path = &config.db_path;
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent).map_err(|error| DevbrainError::IoError(error.to_string()))?;
}
if !db_path.exists() {
let db = Database {
version: default_db_version(),
entries: Vec::new(),
};
save_db(config, &db)?;
}
Ok(())
}
pub fn load_db(config: &Config) -> Result<Database, DevbrainError> {
let db_path = &config.db_path;
if !db_path.exists() {
init_db(config)?;
}
let mut file = OpenOptions::new()
.read(true)
.open(db_path)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
with_shared_lock(&mut file, |file| {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
parse_database(&contents, db_path)
})
}
pub fn save_db(config: &Config, db: &Database) -> Result<(), DevbrainError> {
let db_path = &config.db_path;
let json = serde_json::to_string_pretty(db)
.map_err(|error| DevbrainError::ParseError(error.to_string()))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(db_path)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
with_exclusive_lock(&mut file, |file| {
file.set_len(0)
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
file.seek(SeekFrom::Start(0))
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
file.write_all(json.as_bytes())
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
file.sync_all()
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
Ok(())
})
}
pub fn add_entry(config: &Config, entry: Entry) -> Result<(), DevbrainError> {
let mut db = load_db(config)?;
db.entries.push(entry);
save_db(config, &db)?;
Ok(())
}
fn with_shared_lock<T, F>(file: &mut std::fs::File, action: F) -> Result<T, DevbrainError>
where
F: FnOnce(&mut std::fs::File) -> Result<T, DevbrainError>,
{
file.lock_shared()
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let result = action(file);
finish_locked_operation(file, result)
}
fn with_exclusive_lock<T, F>(file: &mut std::fs::File, action: F) -> Result<T, DevbrainError>
where
F: FnOnce(&mut std::fs::File) -> Result<T, DevbrainError>,
{
file.lock_exclusive()
.map_err(|error| DevbrainError::IoError(error.to_string()))?;
let result = action(file);
finish_locked_operation(file, result)
}
fn finish_locked_operation<T>(
file: &std::fs::File,
result: Result<T, DevbrainError>,
) -> Result<T, DevbrainError> {
let unlock_result = file.unlock().map_err(|error| {
DevbrainError::IoError(format!("failed to release database lock: {error}"))
});
match (result, unlock_result) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(error)) => Err(error),
(Err(error), Err(_)) => Err(error),
}
}
fn parse_database(contents: &str, path: &Path) -> Result<Database, DevbrainError> {
let value: Value = serde_json::from_str(contents).map_err(|error| {
DevbrainError::ParseError(format!(
"Database at {} is corrupted or invalid JSON: {}",
path.display(),
error
))
})?;
if !value.is_object() {
return Err(DevbrainError::ParseError(format!(
"Database at {} has an invalid format. Expected a JSON object.",
path.display()
)));
}
serde_json::from_value(value).map_err(|error| {
DevbrainError::ParseError(format!(
"Database at {} has an invalid schema: {}",
path.display(),
error
))
})
}