feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
157
crates/application/src/chat_drafts.rs
Normal file
157
crates/application/src/chat_drafts.rs
Normal file
@ -0,0 +1,157 @@
|
||||
//! Custom chat draft use cases.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{ChatDraftStore, Clock, ProjectStore};
|
||||
use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId};
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
/// Input identifying one draft scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChatDraftScopeInput {
|
||||
/// Project owning the layout.
|
||||
pub project_id: ProjectId,
|
||||
/// Named layout containing the chat cell.
|
||||
pub layout_id: LayoutId,
|
||||
/// Layout node hosting the chat cell.
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
impl ChatDraftScopeInput {
|
||||
fn key(self) -> ChatDraftKey {
|
||||
ChatDraftKey::new(self.project_id, self.layout_id, self.node_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of [`ReadChatDraft`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadChatDraftOutput {
|
||||
/// Restored draft text, or `None` when no draft exists.
|
||||
pub text: Option<String>,
|
||||
/// Last update time when a draft exists.
|
||||
pub updated_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Input for [`SaveChatDraft`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveChatDraftInput {
|
||||
/// Draft scope.
|
||||
pub scope: ChatDraftScopeInput,
|
||||
/// Text to persist. Empty text clears the draft.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Reads a persisted custom chat draft.
|
||||
pub struct ReadChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
}
|
||||
|
||||
impl ReadChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
|
||||
Self { projects, drafts }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ChatDraftScopeInput,
|
||||
) -> Result<ReadChatDraftOutput, AppError> {
|
||||
let project = self.load_project(input.project_id).await?;
|
||||
let draft = self.drafts.read(&project.root, input.key()).await?;
|
||||
Ok(ReadChatDraftOutput {
|
||||
text: draft.as_ref().map(|draft| draft.text.clone()),
|
||||
updated_at_ms: draft.map(|draft| draft.updated_at_ms),
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a persisted custom chat draft.
|
||||
pub struct SaveChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl SaveChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
projects,
|
||||
drafts,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the save. Empty text clears the draft.
|
||||
pub async fn execute(&self, input: SaveChatDraftInput) -> Result<(), AppError> {
|
||||
let project = self.load_project(input.scope.project_id).await?;
|
||||
let key = input.scope.key();
|
||||
if input.text.is_empty() {
|
||||
return self
|
||||
.drafts
|
||||
.clear(&project.root, key)
|
||||
.await
|
||||
.map_err(AppError::from);
|
||||
}
|
||||
let updated_at_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
||||
let draft = ChatDraft::new(key, input.text, updated_at_ms)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.drafts
|
||||
.save(&project.root, draft)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a persisted custom chat draft.
|
||||
pub struct ClearChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
}
|
||||
|
||||
impl ClearChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
|
||||
Self { projects, drafts }
|
||||
}
|
||||
|
||||
/// Executes the clear.
|
||||
pub async fn execute(&self, input: ChatDraftScopeInput) -> Result<(), AppError> {
|
||||
let project = self.load_project(input.project_id).await?;
|
||||
self.drafts
|
||||
.clear(&project.root, input.key())
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
pub mod agent;
|
||||
pub mod background;
|
||||
pub mod chat_attachments;
|
||||
pub mod chat_drafts;
|
||||
pub mod conversation;
|
||||
pub mod device;
|
||||
pub mod diag;
|
||||
@ -76,6 +77,10 @@ pub use chat_attachments::{
|
||||
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use chat_drafts::{
|
||||
ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, ReadChatDraftOutput, SaveChatDraft,
|
||||
SaveChatDraftInput,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
|
||||
230
crates/application/tests/chat_drafts.rs
Normal file
230
crates/application/tests/chat_drafts.rs
Normal file
@ -0,0 +1,230 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ChatDraftStore, Clock, ProjectStore, StoreError};
|
||||
use domain::{
|
||||
ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId, ProjectPath, RemoteRef,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, SaveChatDraft, SaveChatDraftInput,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProjects {
|
||||
projects: Mutex<HashMap<ProjectId, Project>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.projects.lock().unwrap().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.projects
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
self.projects
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(project.id, project.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(
|
||||
&self,
|
||||
_workspace: &domain::layout::Workspace,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<domain::layout::Workspace, StoreError> {
|
||||
Err(StoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeDrafts {
|
||||
drafts: Mutex<HashMap<ChatDraftKey, ChatDraft>>,
|
||||
saves: Mutex<Vec<ChatDraft>>,
|
||||
clears: Mutex<Vec<ChatDraftKey>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatDraftStore for FakeDrafts {
|
||||
async fn read(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
key: ChatDraftKey,
|
||||
) -> Result<Option<ChatDraft>, StoreError> {
|
||||
Ok(self.drafts.lock().unwrap().get(&key).cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, _root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> {
|
||||
self.saves.lock().unwrap().push(draft.clone());
|
||||
self.drafts.lock().unwrap().insert(draft.key, draft);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear(&self, _root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> {
|
||||
self.clears.lock().unwrap().push(key);
|
||||
self.drafts.lock().unwrap().remove(&key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
1234
|
||||
}
|
||||
}
|
||||
|
||||
fn pid() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
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 project() -> Project {
|
||||
Project::new(
|
||||
pid(),
|
||||
"P",
|
||||
ProjectPath::new("/tmp/idea-chat-drafts-test").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn scope(layout_id: LayoutId, node_id: NodeId) -> ChatDraftScopeInput {
|
||||
ChatDraftScopeInput {
|
||||
project_id: pid(),
|
||||
layout_id,
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_read_chat_draft_round_trip_text_only() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let read = ReadChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
);
|
||||
|
||||
save.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: "hello draft".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let output = read.execute(scope(lid(2), nid(3))).await.unwrap();
|
||||
assert_eq!(output.text.as_deref(), Some("hello draft"));
|
||||
assert_eq!(output.updated_at_ms, Some(1234));
|
||||
assert_eq!(drafts.saves.lock().unwrap()[0].text, "hello draft");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_empty_text_clears_draft() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let key = ChatDraftKey::new(pid(), lid(2), nid(3));
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, ChatDraft::new(key, "old", 1).unwrap());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
save.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(drafts.drafts.lock().unwrap().is_empty());
|
||||
assert_eq!(drafts.clears.lock().unwrap().as_slice(), &[key]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_chat_draft_is_scoped_by_layout_and_node() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let keep = ChatDraftKey::new(pid(), lid(2), nid(4));
|
||||
let clear = ChatDraftKey::new(pid(), lid(2), nid(3));
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(keep, ChatDraft::new(keep, "keep", 1).unwrap());
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(clear, ChatDraft::new(clear, "clear", 1).unwrap());
|
||||
let usecase = ClearChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
);
|
||||
|
||||
usecase.execute(scope(lid(2), nid(3))).await.unwrap();
|
||||
|
||||
let drafts = drafts.drafts.lock().unwrap();
|
||||
assert!(drafts.contains_key(&keep));
|
||||
assert!(!drafts.contains_key(&clear));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_chat_draft_is_rejected_before_store_call() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = save
|
||||
.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(drafts.saves.lock().unwrap().is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user