126 lines
3.6 KiB
Rust
126 lines
3.6 KiB
Rust
//! Filesystem custom chat draft store.
|
|
//!
|
|
//! Drafts are persisted per project under `<root>/.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<ChatDraft>,
|
|
}
|
|
|
|
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<ChatDraftsDoc, StoreError> {
|
|
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<Option<ChatDraft>, 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
|
|
}
|
|
}
|