//! Filesystem custom chat draft store. //! //! Drafts are persisted per project under `/.ideai/chat-drafts.json`. use std::path::PathBuf; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ports::{ChatDraftStore, StoreError}; use domain::{ChatDraft, ChatDraftKey, ProjectPath}; const IDEAI_DIR: &str = ".ideai"; const CHAT_DRAFTS_FILE: &str = "chat-drafts.json"; const CHAT_DRAFTS_TMP_FILE: &str = "chat-drafts.json.tmp"; const CHAT_DRAFTS_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ChatDraftsDoc { version: u32, drafts: Vec, } impl Default for ChatDraftsDoc { fn default() -> Self { Self { version: CHAT_DRAFTS_VERSION, drafts: Vec::new(), } } } /// Filesystem-backed [`ChatDraftStore`]. #[derive(Debug, Clone, Default)] pub struct FsChatDraftStore; impl FsChatDraftStore { /// Builds a store. #[must_use] pub const fn new() -> Self { Self } } fn dir(root: &ProjectPath) -> PathBuf { PathBuf::from(root.as_str()).join(IDEAI_DIR) } fn path(root: &ProjectPath) -> PathBuf { dir(root).join(CHAT_DRAFTS_FILE) } fn tmp_path(root: &ProjectPath) -> PathBuf { dir(root).join(CHAT_DRAFTS_TMP_FILE) } fn io_error(err: std::io::Error) -> StoreError { StoreError::Io(err.to_string()) } async fn read_doc(root: &ProjectPath) -> Result { match tokio::fs::read(path(root)).await { Ok(bytes) => { let doc: ChatDraftsDoc = serde_json::from_slice(&bytes) .map_err(|err| StoreError::Serialization(err.to_string()))?; if doc.version != CHAT_DRAFTS_VERSION { return Err(StoreError::Invalid(format!( "unsupported chat drafts version {}", doc.version ))); } Ok(doc) } Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ChatDraftsDoc::default()), Err(err) => Err(io_error(err)), } } async fn write_doc(root: &ProjectPath, doc: &ChatDraftsDoc) -> Result<(), StoreError> { let dir = dir(root); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; let bytes = serde_json::to_vec_pretty(doc).map_err(|err| StoreError::Serialization(err.to_string()))?; let tmp = tmp_path(root); tokio::fs::write(&tmp, bytes).await.map_err(io_error)?; tokio::fs::rename(tmp, path(root)).await.map_err(io_error) } #[async_trait] impl ChatDraftStore for FsChatDraftStore { async fn read( &self, root: &ProjectPath, key: ChatDraftKey, ) -> Result, StoreError> { Ok(read_doc(root) .await? .drafts .into_iter() .find(|draft| draft.key == key)) } async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> { let mut doc = read_doc(root).await?; if let Some(slot) = doc .drafts .iter_mut() .find(|existing| existing.key == draft.key) { *slot = draft; } else { doc.drafts.push(draft); } doc.drafts .sort_by_key(|draft| (draft.key.project_id, draft.key.layout_id, draft.key.node_id)); write_doc(root, &doc).await } async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> { let mut doc = read_doc(root).await?; doc.drafts.retain(|draft| draft.key != key); write_doc(root, &doc).await } }