ghtui 0.2.0

A keyboard-driven terminal client for GitHub, with a pull request diff viewer that goes further than GitHub's
//! Writes waiting for GitHub ([`crate::offline::queue`]), kept in the
//! cache's database, apart for each account: one row per write, by its
//! number, so each change to one is its own transaction, written before
//! anything is sent.

use redb::{ReadableDatabase, TableDefinition};
use serde::{Serialize, de::DeserializeOwned};

use super::{Store, StoreError};

/// Rows keyed by `<account scope>queue/<number, 20 digits>`, so an
/// account's are together and in order.
const QUEUE: TableDefinition<&str, &[u8]> = TableDefinition::new("queue");

impl Store {
    fn queue_key(&self, id: u64) -> String {
        self.scoped(&format!("queue/{id:020}"))
    }

    /// Writes (or rewrites) row `id`, durably, before returning; false
    /// when it couldn't (the store is disabled, or the disk refused).
    pub fn queue_put<T: Serialize>(&self, id: u64, value: &T) -> bool {
        let Some(db) = self.db.as_ref() else {
            return false;
        };
        let key = self.queue_key(id);
        let result = (|| -> Result<(), StoreError> {
            let bytes = serde_json::to_vec(value)?;
            let txn = db.database.begin_write()?;
            {
                let mut table = txn.open_table(QUEUE)?;
                table.insert(key.as_str(), bytes.as_slice())?;
            }
            txn.commit()?;
            Ok(())
        })();
        result
            .inspect_err(|err| tracing::warn!(%err, id, "couldn't keep a queued write"))
            .is_ok()
    }

    /// Takes row `id` out.
    pub fn queue_remove(&self, id: u64) -> bool {
        let Some(db) = self.db.as_ref() else {
            return false;
        };
        let key = self.queue_key(id);
        let result = (|| -> Result<(), StoreError> {
            let txn = db.database.begin_write()?;
            {
                let mut table = txn.open_table(QUEUE)?;
                table.remove(key.as_str())?;
            }
            txn.commit()?;
            Ok(())
        })();
        result
            .inspect_err(|err| tracing::warn!(%err, id, "couldn't take out a queued write"))
            .is_ok()
    }

    /// This account's rows, in order; a row that no longer decodes is
    /// left out (and said in the log).
    pub fn queue_all<T: DeserializeOwned>(&self) -> Vec<(u64, T)> {
        let Some(db) = self.db.as_ref() else {
            return Vec::new();
        };
        let prefix = self.scoped("queue/");
        let result = (|| -> Result<Vec<(u64, T)>, StoreError> {
            let txn = db.database.begin_read()?;
            let table = match txn.open_table(QUEUE) {
                Ok(table) => table,
                Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
                Err(err) => return Err(err.into()),
            };
            let mut rows = Vec::new();
            for row in table.range(prefix.as_str()..)? {
                let (key, value) = row?;
                let Some(id) = key.value().strip_prefix(prefix.as_str()) else {
                    break;
                };
                let Ok(id) = id.parse::<u64>() else { continue };
                match serde_json::from_slice(value.value()) {
                    Ok(value) => rows.push((id, value)),
                    Err(err) => tracing::warn!(%err, id, "a queued write no longer reads"),
                }
            }
            Ok(rows)
        })();
        result.unwrap_or_else(|err| {
            tracing::warn!(%err, "couldn't read the queued writes");
            Vec::new()
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rows_keep_their_order_apart_for_each_account() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("cache.redb");
        {
            let alice = Store::open(&path).for_account("github.com", "aaaa");
            alice.signed_in("alice");
            let bob = alice.clone().for_account("github.com", "bbbb");
            bob.signed_in("bob");
            // Ten after nine, as numbers.
            assert!(alice.queue_put(10, &"ten"));
            assert!(alice.queue_put(9, &"nine"));
            assert!(bob.queue_put(1, &"bob's"));
            assert!(alice.queue_remove(9));
            assert!(alice.queue_put(11, &"eleven"));
        }
        // After a restart, as they were.
        let alice = Store::open(&path).for_account("github.com", "aaaa");
        let rows: Vec<(u64, String)> = alice.queue_all();
        assert_eq!(rows, [(10, "ten".into()), (11, "eleven".into())]);
        let other = alice.for_account("ghe.example.com", "aaaa");
        assert!(other.queue_all::<String>().is_empty());
    }
}