tuible 0.0.2-alpha.1

A keyboard-driven database client for your terminal, built for both humans and AI agents.
use std::fmt;
use std::io::Write as _;
use std::path::{Path, PathBuf};

use fs2::FileExt as _;
use serde::{Deserialize, Serialize};

use crate::config;

const HISTORY_VERSION: u32 = 1;
pub const MAX_QUERY_HISTORY: usize = 100;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct QueryHistory {
    entries: Vec<String>,
}

impl QueryHistory {
    pub fn entries(&self) -> &[String] {
        &self.entries
    }

    pub fn record(&mut self, statement: &str) -> bool {
        let statement = statement.trim();
        if statement.is_empty() || self.entries.last().is_some_and(|entry| entry == statement) {
            return false;
        }

        self.entries.retain(|entry| entry != statement);
        self.entries.push(statement.to_string());
        let excess = self.entries.len().saturating_sub(MAX_QUERY_HISTORY);
        if excess > 0 {
            self.entries.drain(..excess);
        }
        true
    }

    fn from_entries(entries: Vec<String>) -> Self {
        let mut normalized = Vec::with_capacity(entries.len().min(MAX_QUERY_HISTORY));
        for statement in entries.into_iter().rev() {
            let statement = statement.trim();
            if statement.is_empty() || normalized.iter().any(|entry| entry == statement) {
                continue;
            }
            normalized.push(statement.to_string());
            if normalized.len() == MAX_QUERY_HISTORY {
                break;
            }
        }
        normalized.reverse();
        Self {
            entries: normalized,
        }
    }
}

#[derive(Debug)]
pub struct LoadedHistory {
    pub history: QueryHistory,
    pub issue: Option<HistoryLoadIssue>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HistoryLoadIssue {
    Malformed,
    UnsupportedVersion(u64),
}

impl fmt::Display for HistoryLoadIssue {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HistoryLoadIssue::Malformed => {
                write!(formatter, "query history is malformed; file left unchanged")
            }
            HistoryLoadIssue::UnsupportedVersion(version) => write!(
                formatter,
                "query history version {version} is unsupported; file left unchanged"
            ),
        }
    }
}

#[derive(Debug, Clone)]
pub struct QueryHistoryStore {
    path: PathBuf,
}

impl QueryHistoryStore {
    pub fn from_xdg() -> anyhow::Result<Self> {
        Ok(Self {
            path: config::state_dir()?.join("query-history.json"),
        })
    }

    #[cfg(test)]
    pub fn at(path: PathBuf) -> Self {
        Self { path }
    }

    pub fn load(&self) -> anyhow::Result<LoadedHistory> {
        let content = match std::fs::read(&self.path) {
            Ok(content) => content,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(LoadedHistory {
                    history: QueryHistory::default(),
                    issue: None,
                });
            }
            Err(error) => return Err(error.into()),
        };
        let value: serde_json::Value = match serde_json::from_slice(&content) {
            Ok(value) => value,
            Err(_) => {
                return Ok(LoadedHistory {
                    history: QueryHistory::default(),
                    issue: Some(HistoryLoadIssue::Malformed),
                });
            }
        };
        let Some(version) = value.get("version").and_then(serde_json::Value::as_u64) else {
            return Ok(LoadedHistory {
                history: QueryHistory::default(),
                issue: Some(HistoryLoadIssue::Malformed),
            });
        };
        if version != u64::from(HISTORY_VERSION) {
            return Ok(LoadedHistory {
                history: QueryHistory::default(),
                issue: Some(HistoryLoadIssue::UnsupportedVersion(version)),
            });
        }
        let file: HistoryFile = match serde_json::from_value(value) {
            Ok(file) => file,
            Err(_) => {
                return Ok(LoadedHistory {
                    history: QueryHistory::default(),
                    issue: Some(HistoryLoadIssue::Malformed),
                });
            }
        };
        Ok(LoadedHistory {
            history: QueryHistory::from_entries(file.entries),
            issue: None,
        })
    }

    pub fn record(&self, statement: &str) -> anyhow::Result<QueryHistory> {
        let parent = self
            .path
            .parent()
            .ok_or_else(|| anyhow::anyhow!("query history path has no parent"))?;
        std::fs::create_dir_all(parent)?;
        let lock = std::fs::OpenOptions::new()
            .create(true)
            .truncate(false)
            .read(true)
            .write(true)
            .open(parent.join("query-history.lock"))?;
        lock.lock_exclusive()?;

        let loaded = self.load()?;
        if let Some(issue) = loaded.issue {
            anyhow::bail!(issue.to_string());
        }
        let mut history = loaded.history;
        history.record(statement);
        self.write_atomic(parent, &history)?;
        Ok(history)
    }

    fn write_atomic(&self, parent: &Path, history: &QueryHistory) -> anyhow::Result<()> {
        let file = HistoryFile {
            version: HISTORY_VERSION,
            entries: history.entries.clone(),
        };
        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
        serde_json::to_writer_pretty(&mut temporary, &file)?;
        temporary.write_all(b"\n")?;
        temporary.as_file().sync_all()?;
        temporary
            .persist(&self.path)
            .map_err(|error| anyhow::Error::new(error.error))?;
        #[cfg(unix)]
        std::fs::File::open(parent)?.sync_all()?;
        Ok(())
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct HistoryFile {
    version: u32,
    entries: Vec<String>,
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used, clippy::unwrap_used)]

    use super::*;
    use tempfile::tempdir;

    #[test]
    fn history_is_deduplicated_and_bounded_to_the_newest_entries() {
        let dir = tempdir().unwrap();
        let store = QueryHistoryStore::at(dir.path().join("tuible/query-history.json"));

        for index in 0..105 {
            store.record(&format!("SELECT {index}")).unwrap();
        }
        store.record("SELECT 50").unwrap();
        store.record("SELECT 50").unwrap();
        let loaded = store.load().unwrap();

        assert!(loaded.issue.is_none());
        assert_eq!(loaded.history.entries().len(), MAX_QUERY_HISTORY);
        assert_eq!(
            loaded.history.entries().last().map(String::as_str),
            Some("SELECT 50")
        );
        assert_eq!(
            loaded
                .history
                .entries()
                .iter()
                .filter(|entry| entry.as_str() == "SELECT 50")
                .count(),
            1
        );
        assert_eq!(
            loaded.history.entries().first().map(String::as_str),
            Some("SELECT 5")
        );
    }

    #[test]
    fn malformed_history_is_reported_and_never_overwritten() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("query-history.json");
        let malformed = b"{not valid json";
        std::fs::write(&path, malformed).unwrap();
        let store = QueryHistoryStore::at(path.clone());

        let loaded = store.load().unwrap();
        assert_eq!(loaded.issue, Some(HistoryLoadIssue::Malformed));
        assert!(loaded.history.entries().is_empty());
        assert!(
            loaded
                .issue
                .as_ref()
                .is_some_and(|issue| issue.to_string().contains("left unchanged"))
        );

        let error = store.record("SELECT 1").unwrap_err();
        assert!(error.to_string().contains("malformed"));
        assert_eq!(std::fs::read(path).unwrap(), malformed);
    }

    #[test]
    fn unsupported_history_versions_are_reported_and_never_overwritten() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("query-history.json");
        let future = br#"{"version":99,"entries":{"future":"shape"}}"#;
        std::fs::write(&path, future).unwrap();
        let store = QueryHistoryStore::at(path.clone());

        let loaded = store.load().unwrap();
        assert_eq!(loaded.issue, Some(HistoryLoadIssue::UnsupportedVersion(99)));

        let error = store.record("SELECT 1").unwrap_err();
        assert!(error.to_string().contains("version 99"));
        assert_eq!(std::fs::read(path).unwrap(), future);
    }

    #[test]
    fn persisted_history_contains_statements_but_no_connection_or_credential_metadata() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("query-history.json");
        let store = QueryHistoryStore::at(path.clone());

        store.record("SELECT * FROM books").unwrap();
        let value: serde_json::Value =
            serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
        let object = value.as_object().unwrap();

        assert_eq!(object.len(), 2);
        assert!(object.contains_key("version"));
        assert!(object.contains_key("entries"));
        assert!(!object.contains_key("profile"));
        assert!(!object.contains_key("credentials"));
        assert!(!object.contains_key("endpoint"));
    }

    #[test]
    fn concurrent_records_are_atomic_and_do_not_lose_entries() {
        let dir = tempdir().unwrap();
        let store = QueryHistoryStore::at(dir.path().join("tuible/query-history.json"));
        let writers: Vec<_> = (0..8)
            .map(|index| {
                let store = store.clone();
                std::thread::spawn(move || store.record(&format!("SELECT {index}")))
            })
            .collect();

        for writer in writers {
            writer.join().unwrap().unwrap();
        }
        let loaded = store.load().unwrap();

        assert_eq!(loaded.history.entries().len(), 8);
        for index in 0..8 {
            assert!(
                loaded
                    .history
                    .entries()
                    .contains(&format!("SELECT {index}"))
            );
        }
        let names: Vec<_> = std::fs::read_dir(dir.path().join("tuible"))
            .unwrap()
            .map(|entry| entry.unwrap().file_name())
            .collect();
        assert_eq!(names.len(), 2);
    }
}