timeid 0.0.1

A simple crate to generate unique, timestamp-based IDs with memo/tag support stored in local SQLite
Documentation
use chrono::{DateTime, Local, TimeZone};
use directories::ProjectDirs;
use once_cell::sync::Lazy;
use rusqlite::{params, Connection, Result};
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};

const CHARSET: &str = "23456789abcdefghijkmnpqrstuvwxyz";
static INSTANCE: Lazy<Mutex<Option<TimeId>>> = Lazy::new(|| Mutex::new(None));

#[derive(Debug)]
pub struct TimeId {
    charset: Vec<char>,
    #[allow(dead_code)]
    db_path: PathBuf,
    conn: Connection,
}

impl TimeId {
    /// Returns the default platform-aware path to the SQLite DB
    fn default_db_path() -> PathBuf {
        if let Some(proj_dirs) = ProjectDirs::from("com", "nicklawman", "timeid") {
            let db_dir = proj_dirs.data_local_dir();
            db_dir.join("timeid.sqlite")
        } else {
            // Fallback in case ProjectDirs fails
            PathBuf::from(home::home_dir().unwrap()).join(".timeid.sqlite")
        }
    }

    /// Initializes the TimeId instance with the given path or default
    fn init(db_path: Option<&str>) -> Result<Self> {
        let db_path = db_path
            .map(PathBuf::from)
            .unwrap_or_else(Self::default_db_path);

        if let Some(parent) = db_path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        }

        let conn = Connection::open(&db_path)?;
        conn.execute(
            "CREATE TABLE IF NOT EXISTS used_ids (
                id TEXT PRIMARY KEY,
                memo TEXT DEFAULT '',
                tag TEXT DEFAULT ''
            )",
            [],
        )?;

        Ok(Self {
            charset: CHARSET.chars().collect(),
            db_path,
            conn,
        })
    }

    fn global() -> Result<MutexGuard<'static, Option<TimeId>>> {
        let mut guard = INSTANCE.lock().unwrap();
        if guard.is_none() {
            *guard = Some(Self::init(None)?);
        }
        Ok(guard)
    }

    pub fn gen() -> Result<String> {
        let mut guard = Self::global()?;
        let instance = guard.as_mut().unwrap();
        instance.gen_inner("", "")
    }

    pub fn parse(uid: &str) -> String {
        let guard = INSTANCE.lock().unwrap();
        let instance = guard
            .as_ref()
            .expect("TimeId must be initialized before calling parse()");
        instance.parse_inner(uid)
    }

    pub fn gen_inner(&mut self, memo: &str, tag: &str) -> Result<String> {
        let mut timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let mut uid = self.encode_timestamp(timestamp);
        while self.exists(&uid)? {
            timestamp += 1;
            uid = self.encode_timestamp(timestamp);
        }
        self.store(&uid, memo, tag)?;
        Ok(uid)
    }

    pub fn reset(db_path: Option<&str>) -> Result<()> {
        let path = db_path
            .map(PathBuf::from)
            .unwrap_or_else(Self::default_db_path);

        if path.exists() {
            fs::remove_file(&path)
                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        }

        let new_instance = Some(Self::init(Some(path.to_str().unwrap()))?);
        let mut guard = INSTANCE.lock().unwrap();
        *guard = new_instance;
        Ok(())
    }

    pub fn status() -> Result<usize> {
        let guard = Self::global()?;
        let instance = guard.as_ref().unwrap();
        let count: usize = instance
            .conn
            .query_row("SELECT COUNT(*) FROM used_ids", [], |row| row.get(0))?;
        Ok(count)
    }

    pub fn parse_inner(&self, uid: &str) -> String {
        let timestamp = self.decode_timestamp(uid);
        let datetime: DateTime<Local> = Local.timestamp_opt(timestamp as i64, 0).unwrap();
        datetime.format("%Y-%m-%d %H:%M:%S").to_string()
    }

    fn encode_base(&self, mut num: u64) -> String {
        if num == 0 {
            return self.charset[0].to_string();
        }
        let base = self.charset.len() as u64;
        let mut encoded = String::new();
        while num > 0 {
            let rem = (num % base) as usize;
            encoded.insert(0, self.charset[rem]);
            num /= base;
        }
        encoded
    }

    fn decode_base(&self, uid: &str) -> u64 {
        let base = self.charset.len() as u64;
        uid.chars().fold(0, |acc, c| {
            acc * base + self.charset.iter().position(|&x| x == c).unwrap() as u64
        })
    }

    fn encode_timestamp(&self, timestamp: u64) -> String {
        self.encode_base(timestamp * self.charset.len() as u64)
    }

    fn decode_timestamp(&self, uid: &str) -> u64 {
        self.decode_base(uid) / self.charset.len() as u64
    }

    fn exists(&self, uid: &str) -> Result<bool> {
        let mut stmt = self.conn.prepare("SELECT id FROM used_ids WHERE id = ?1")?;
        let mut rows = stmt.query(params![uid])?;
        Ok(rows.next()?.is_some())
    }

    fn store(&self, uid: &str, memo: &str, tag: &str) -> Result<()> {
        self.conn.execute(
            "INSERT INTO used_ids (id, memo, tag) VALUES (?1, ?2, ?3)",
            params![uid, memo, tag],
        )?;
        Ok(())
    }
}