use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use serde::{Deserialize, Serialize};
use crate::channels::ReplyReference;
use crate::util::UnwrapPoison;
const DRAFT_FILE_NAME: &str = "chat-draft.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct ChatDraft {
#[serde(default)]
pub(crate) text: String,
#[serde(default)]
pub(crate) reply: Option<ReplyReference>,
}
impl ChatDraft {
#[must_use]
fn is_empty(&self) -> bool {
self.text.trim().is_empty() && self.reply.is_none()
}
}
pub(crate) struct DraftStore {
path: Option<PathBuf>,
draft: Mutex<ChatDraft>,
}
impl DraftStore {
#[must_use]
fn file_path() -> Option<PathBuf> {
crate::config::default_config_dir()
.ok()
.map(|dir| dir.join(DRAFT_FILE_NAME))
}
fn load(path: Option<PathBuf>) -> Self {
let draft: ChatDraft = path
.as_deref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
if draft.is_empty()
&& let Some(p) = &path
{
let _ = std::fs::remove_file(p);
}
Self {
path,
draft: Mutex::new(draft),
}
}
#[must_use]
pub(crate) fn global() -> &'static Arc<DraftStore> {
static GLOBAL: LazyLock<Arc<DraftStore>> =
LazyLock::new(|| Arc::new(DraftStore::load(DraftStore::file_path())));
&GLOBAL
}
#[cfg(test)]
#[must_use]
pub(crate) fn at(path: Option<PathBuf>) -> Arc<DraftStore> {
Arc::new(DraftStore::load(path))
}
pub(crate) fn set(&self, text: String, reply: Option<ReplyReference>) {
if self.path.is_none() {
return;
}
*self.draft.lock().unwrap_poison() = ChatDraft { text, reply };
}
pub(crate) fn remove(&self) {
if self.path.is_none() {
return;
}
*self.draft.lock().unwrap_poison() = ChatDraft::default();
}
#[must_use]
pub(crate) fn get(&self) -> ChatDraft {
if self.path.is_none() {
return ChatDraft::default();
}
self.draft.lock().unwrap_poison().clone()
}
pub(crate) fn persist(&self) {
let Some(path) = &self.path else {
return;
};
let draft = self.draft.lock().unwrap_poison();
if draft.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
if let Ok(json) = serde_json::to_string(&*draft) {
let _ = crate::util::write_json_record(path, &json);
}
}
pub(crate) fn persist_async(self: Arc<Self>) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn_blocking(move || self.persist());
}
}
}
pub(crate) fn flush_global() {
DraftStore::global().persist();
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (tempfile::TempDir, Arc<DraftStore>) {
let dir = tempfile::tempdir().unwrap();
let store = DraftStore::at(Some(dir.path().join(DRAFT_FILE_NAME)));
(dir, store)
}
fn reply(author: &str) -> ReplyReference {
ReplyReference {
author: author.to_string(),
snippet: "snippet".to_string(),
}
}
#[test]
fn round_trip_set_get_persist_reload() {
let (dir, store) = temp_store();
let path = dir.path().join(DRAFT_FILE_NAME);
store.set("hello".to_string(), Some(reply("Assistant")));
assert_eq!(store.get().text, "hello");
assert_eq!(store.get().reply, Some(reply("Assistant")));
store.persist();
let reloaded = DraftStore::at(Some(path));
assert_eq!(reloaded.get().text, "hello");
assert_eq!(reloaded.get().reply, Some(reply("Assistant")));
}
#[test]
fn corrupt_file_fails_open_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DRAFT_FILE_NAME);
std::fs::write(&path, "not valid json{").unwrap();
let store = DraftStore::at(Some(path));
assert!(store.get().is_empty());
}
#[test]
fn legacy_per_workspace_file_loads_as_no_draft_and_is_removed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DRAFT_FILE_NAME);
std::fs::write(&path, r#"{"admin":{"ws1":{"text":"old","reply":null}}}"#).unwrap();
let store = DraftStore::at(Some(path.clone()));
assert!(store.get().is_empty());
assert!(
!path.exists(),
"the old per-workspace entry must not linger"
);
}
#[test]
fn empty_draft_clears_the_stored_one() {
let (_dir, store) = temp_store();
store.set("hello".to_string(), None);
store.set(" ".to_string(), None);
assert!(store.get().is_empty());
}
#[test]
fn a_reply_without_text_is_a_draft() {
let (dir, store) = temp_store();
let path = dir.path().join(DRAFT_FILE_NAME);
store.set(String::new(), Some(reply("Assistant")));
store.persist();
assert!(path.exists());
assert_eq!(
DraftStore::at(Some(path)).get().reply,
Some(reply("Assistant"))
);
}
#[test]
fn persist_leaves_no_tmp_file_behind() {
let (dir, store) = temp_store();
let path = dir.path().join(DRAFT_FILE_NAME);
store.set("hello".to_string(), None);
store.persist();
assert!(path.exists());
assert!(!path.with_extension("json.tmp").exists());
}
#[test]
fn an_empty_draft_removes_the_file() {
let (dir, store) = temp_store();
let path = dir.path().join(DRAFT_FILE_NAME);
store.set("hello".to_string(), None);
store.persist();
store.remove();
store.persist();
assert!(!path.exists());
}
}