devbrain 0.2.0

Local-first CLI to capture, search, and recall developer workflow (commands, errors, and fixes)
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
        ))
    })
}