devbrain 0.1.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 std::fs::{self, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};

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()))?;
    file.lock_shared()
        .map_err(|error| DevbrainError::IoError(error.to_string()))?;

    let mut contents = String::new();
    let read_result = file
        .read_to_string(&mut contents)
        .map_err(|error| DevbrainError::IoError(error.to_string()));
    let unlock_result = file
        .unlock()
        .map_err(|error| DevbrainError::IoError(error.to_string()));

    read_result?;
    unlock_result?;

    let db = serde_json::from_str(&contents)
        .map_err(|error| DevbrainError::ParseError(error.to_string()))?;
    Ok(db)
}

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()))?;
    file.lock_exclusive()
        .map_err(|error| DevbrainError::IoError(error.to_string()))?;

    let write_result = (|| -> Result<(), DevbrainError> {
        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(())
    })();
    let unlock_result = file
        .unlock()
        .map_err(|error| DevbrainError::IoError(error.to_string()));

    write_result?;
    unlock_result?;

    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(())
}