thoughts 3.2.0

A simple cli for recording down any random thoughts you may have :D
Documentation
use std::{fs::{self, File}, io::{Seek, Write}};
use chrono::{DateTime, Datelike, Local, Utc};
use log::{info, warn};

use crate::{database::Database, get_dir, thought::Thought};

/// Exports a thought database as either markdown or RON
pub fn export(markdown: bool, oats: bool, path: &str) {
    if markdown {
        export_markdown(path);
    } else if oats {
        export_oats(path);
    } else {
        export_ron(path);
    }
}

fn export_oats(path: &str) {
    info!("exporting thoughts as oats (`{path}`)...");

    // get both the database and output file
    let database = Database::load(get_dir()).expect("database either corrupt or non-existent");
    let mut file = File::create(path).unwrap();

    // write the magic
    file.write_all("oats".as_bytes()).unwrap();
    file.write_all(&[0]).unwrap();
    file.seek(std::io::SeekFrom::Start(4 + 1 + 8)).unwrap();

    let mut stack_ptr: u64 = 4 + 1 + 8;

    // write the entries
    for bytes in database {
        // deserialize the thought
        let thought = bincode::deserialize(&bytes).expect("thought database is corrupt");

        // extract the thought and time
        let Thought { uid, thought, utc } = thought;

        // length & id
        let length = 8 + 1 + thought.len() as u32 + if utc.is_some() { 8 } else { 0 };
        file.write_all(&length.to_be_bytes()).unwrap();
        file.write_all(&uid.to_be_bytes()).unwrap();

        // if date exists then diff bitfield
        if let Some(utc) = utc {
            file.write_all(&[2]).unwrap();
            file.write_all(&utc.timestamp_millis().to_be_bytes()).unwrap();
        } else {
            file.write_all(&[0]).unwrap();
        }

        // contents
        file.write_all(thought.as_bytes()).unwrap();

        // length
        file.write_all(&length.to_be_bytes()).unwrap();

        stack_ptr += length as u64 + 2 * 4;
    }

    // write the stack ptr
    file.seek(std::io::SeekFrom::Start(4 + 1)).unwrap();
    file.write_all(&stack_ptr.to_be_bytes()).unwrap();
}

fn export_markdown(path: &str) {
    info!("exporting thoughts as markdown (`{path}`)...");

    // get both the database and output file
    let database = Database::load(get_dir()).expect("database either corrupt or non-existent");
    let mut file = File::create(path).unwrap();

    // write the title & initialise the 'last time' variable
    file.write_all("# Thoughts :D\n---\n".as_bytes()).unwrap();
    let mut last: Option<DateTime<Utc>> = None;

    // write the entries
    for bytes in database {
        // deserialize the thought
        let thought = bincode::deserialize(&bytes).expect("thought database is corrupt");

        // extract the thought and time
        let Thought { uid: _, thought, utc } = thought;

        // if there is a timestamp then check it
        // diff day, print date
        // if it's been longer than an 30 mins, print time
        if let Some(utc) = utc {
            // get the last time or otherwise use a generic time
            let last = last.unwrap_or(DateTime::from_timestamp_nanos(0));
            
            // check the day or month or year
            if last.day() != utc.day() || last.month() != utc.month() || last.year() != utc.year() {
                let utc: DateTime<Local> = DateTime::from(utc);

                // format the date
                let format = &format!(
                    "%A, %-d{} of %B %Y `%I:%M %p`",
                    // get the suffix (may replace later with better alternative)
                    match utc.day() {
                        t if t % 10 == 1 && t % 100 != 11 => "st",
                        t if t % 10 == 2 && t % 100 != 12 => "nd",
                        t if t % 10 == 3 && t % 100 != 13 => "rd",
                        _ => "th",
                    }
                );
                let date = format!("## {}\n", utc.format(format));

                // write the formatted date
                file.write_all(date.as_bytes()).unwrap();
            } else if (utc.time() - last.time()).num_minutes() > 16 { // check if it's within 16 minutes
                // format the time and write it
                let time: DateTime<Local> = DateTime::from(utc);
                let time = time.format("`%I:%M %p`");
                let time = format!("#### {time}\n");

                // write the formatted time
                file.write_all(time.as_bytes()).unwrap();
            }
        }

        // update last
        last = utc;

        // write the thought to the file
        let thought = format!("- {thought}\n");
        file.write_all(thought.as_bytes()).unwrap();
    }

    // flush the file
    file.flush().unwrap();
    info!("successfully export thoughts as markdown!");
}

fn export_ron(path: &str) {
    info!("exporting thoughts as RON (`{path}`)...");

    // get database
    let database = Database::load(get_dir()).expect("database either corrupt or non-existent");

    // collect and generate ron
    let ron = database.into_iter()
        .map(|bytes| {
            // deserialize thought
            bincode::deserialize::<Thought>(&bytes).expect("thought database is corrupt")
        })
        .collect::<Vec<_>>();
    let ron = ron::to_string(&ron).unwrap();

    // write the generated ron to the file
    fs::write(path, ron).unwrap();
    info!("successfully export thoughts as RON!");
}

/// Imports RON thoughts and combines it with the current existing database
pub fn import(path: &str) {
    info!("importing RON thoughts and combining with current database...");

    // get the current thoughts from the database if it exists, otherwise create an empty vector
    let mut thoughts = if get_dir().exists() {
        info!("thoughts database found");
        Database::load(get_dir()).unwrap()
            .map(|bytes| {
                // deserialize the thought
                bincode::deserialize(&bytes)
                    .expect("thoughts database is corrupt")
            })
            .collect::<Vec<Thought>>()
    } else {
        warn!("thoughts database not found, initialising a new one");
        Vec::new()
    };

    // get the RON thoughts
    #[allow(clippy::expect_fun_call)]
    let ron_thoughts: Vec<Thought> = ron::from_str(&fs::read_to_string(path).expect(&format!("while reading the contents of `{path}`"))).expect("RON thoughts are corrupt");

    // combine the thoughts and remove duplicates
    for rthought in ron_thoughts.into_iter() {
        if !thoughts.iter().any(|thought| thought.uid == rthought.uid) {
            thoughts.push(rthought);
        }
    }

    // sort the thoughts by time
    thoughts.sort_unstable_by_key(|thought| thought.uid);

    // write the resulting thoughts to a new database
    let _ = fs::remove_dir_all(get_dir());
    let mut database = Database::new(get_dir()).expect("while initialising database");
    for thought in thoughts.into_iter() {
        database.push(
            &bincode::serialize(&thought).unwrap()
        ).expect("while writing to thought database (warning: major data loss)");
    } database.commit().expect("while writing to thought database (warning: major data loss)");

    info!("successfully imported RON thoughts!");
}