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