//! Filesystem chat attachment store. //! //! Project-scoped agent/chat attachments live under //! `/.ideai/attachments/agent-chat//`. use std::path::{Path, PathBuf}; use async_trait::async_trait; use domain::ports::{ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError}; use domain::{AgentId, ChatAttachment, LocalPath, ProjectId, ProjectPath, SessionId}; const IDEAI_DIR: &str = ".ideai"; const ATTACHMENTS_DIR: &str = "attachments"; const AGENT_CHAT_DIR: &str = "agent-chat"; const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; /// Filesystem-backed chat attachment store. #[derive(Debug, Clone, Default)] pub struct FsChatAttachmentStore; impl FsChatAttachmentStore { /// Builds a store. #[must_use] pub const fn new() -> Self { Self } } fn attachment_root(root: &ProjectPath) -> PathBuf { PathBuf::from(root.as_str()) .join(IDEAI_DIR) .join(ATTACHMENTS_DIR) } fn session_dir(root: &ProjectPath, session_id: SessionId) -> PathBuf { attachment_root(root) .join(AGENT_CHAT_DIR) .join(session_id.to_string()) } fn metadata_path(root: &ProjectPath, session_id: SessionId, id: &str) -> PathBuf { session_dir(root, session_id).join(format!("{id}.json")) } fn storage_relative(session_id: SessionId, stored_name: &str) -> String { format!("{AGENT_CHAT_DIR}/{session_id}/{stored_name}") } fn readable_path(root: &ProjectPath, relative: &str) -> String { attachment_root(root) .join(relative) .to_string_lossy() .into_owned() } fn io_error(err: std::io::Error) -> ChatAttachmentStoreError { ChatAttachmentStoreError::Store(err.to_string()) } fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> { if filename.trim().is_empty() || filename.contains('/') || filename.contains('\\') || filename == "." || filename == ".." { return Err(ChatAttachmentStoreError::Invalid( "invalid chat attachment filename".to_owned(), )); } Ok(()) } async fn write_metadata( root: &ProjectPath, session_id: SessionId, attachment: &ChatAttachment, ) -> Result<(), ChatAttachmentStoreError> { let path = metadata_path(root, session_id, attachment.id.as_str()); let bytes = serde_json::to_vec_pretty(attachment) .map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?; tokio::fs::write(path, bytes).await.map_err(io_error) } async fn read_metadata(path: &Path) -> Result { let bytes = tokio::fs::read(path).await.map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { ChatAttachmentStoreError::NotFound } else { io_error(err) } })?; let attachment: ChatAttachment = serde_json::from_slice(&bytes) .map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?; attachment .validate() .map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?; Ok(attachment) } #[async_trait] impl ChatAttachmentStore for FsChatAttachmentStore { async fn import_from_path( &self, root: &ProjectPath, project_id: ProjectId, agent_id: AgentId, session_id: SessionId, source: &LocalPath, import: ChatAttachmentImport, ) -> Result { validate_filename(&import.filename)?; let source_path = PathBuf::from(source.as_str()); let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?; if !meta.is_file() { return Err(ChatAttachmentStoreError::Invalid( "chat attachment source must be a file".to_owned(), )); } if meta.len() > CHAT_ATTACHMENT_MAX_BYTES { return Err(ChatAttachmentStoreError::Invalid(format!( "chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes" ))); } let dir = session_dir(root, session_id); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; let stored_name = format!("{}-{}", import.id.as_str(), import.filename); let dest = dir.join(&stored_name); tokio::fs::copy(&source_path, &dest) .await .map_err(io_error)?; let storage_path = storage_relative(session_id, &stored_name); let attachment = ChatAttachment { id: import.id, project_id, agent_id, session_id, filename: import.filename, mime: import.mime, size_bytes: meta.len(), source_kind: import.source_kind, readable_path: readable_path(root, &storage_path), storage_path, created_at: import.created_at, }; attachment .validate() .map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?; write_metadata(root, session_id, &attachment).await?; Ok(attachment) } async fn list_for_session( &self, root: &ProjectPath, session_id: SessionId, ) -> Result, ChatAttachmentStoreError> { let dir = session_dir(root, session_id); let mut entries = match tokio::fs::read_dir(&dir).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(err) => return Err(io_error(err)), }; let mut attachments = Vec::new(); while let Some(entry) = entries.next_entry().await.map_err(io_error)? { let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } attachments.push(read_metadata(&path).await?); } attachments.sort_by_key(|attachment| attachment.created_at); Ok(attachments) } }