feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
125
crates/infrastructure/src/chat_drafts.rs
Normal file
125
crates/infrastructure/src/chat_drafts.rs
Normal file
@ -0,0 +1,125 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachments;
|
||||
pub mod chat_drafts;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -55,6 +56,7 @@ pub use background_task::{
|
||||
CommandBackgroundRunner,
|
||||
};
|
||||
pub use chat_attachments::FsChatAttachmentStore;
|
||||
pub use chat_drafts::FsChatDraftStore;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
|
||||
110
crates/infrastructure/tests/chat_drafts.rs
Normal file
110
crates/infrastructure/tests/chat_drafts.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use domain::ports::{ChatDraftStore, IdGenerator};
|
||||
use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, ProjectId, ProjectPath};
|
||||
use infrastructure::{FsChatDraftStore, UuidGenerator};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"idea-chat-draft-{tag}-{}",
|
||||
UuidGenerator::new().new_uuid()
|
||||
))
|
||||
}
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(layout: u128, node: u128) -> ChatDraftKey {
|
||||
ChatDraftKey::new(pid(1), lid(layout), nid(node))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_saves_reads_and_replaces_one_scope() {
|
||||
let project_root = temp_dir("roundtrip");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "first", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "second", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let draft = store.read(&root, key(2, 3)).await.unwrap().unwrap();
|
||||
assert_eq!(draft.text, "second");
|
||||
assert_eq!(draft.updated_at_ms, 11);
|
||||
assert!(
|
||||
tokio::fs::metadata(project_root.join(".ideai/chat-drafts.json"))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_scopes_by_layout_and_node() {
|
||||
let project_root = temp_dir("scoped");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 2), "node two", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "node three", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 2)).await.unwrap().unwrap().text,
|
||||
"node two"
|
||||
);
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 3)).await.unwrap().unwrap().text,
|
||||
"node three"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_clear_removes_only_target_scope() {
|
||||
let project_root = temp_dir("clear");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 2), "clear", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "keep", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.clear(&root, key(2, 2)).await.unwrap();
|
||||
|
||||
assert!(store.read(&root, key(2, 2)).await.unwrap().is_none());
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 3)).await.unwrap().unwrap().text,
|
||||
"keep"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
Reference in New Issue
Block a user