Files
IdeA/crates/infrastructure/src/chat_attachments.rs
Blomios 1c80d08f92 feat(attachments): fondation pipeline durable d'attachments agent/chat + sandbox-safe (#154, QA verte)
Socle #154 : entité attachment, store, contrat DTO et routage session. Modules : domain/chat_attachment, application/chat_attachments, infrastructure/chat_attachments, app-tauri (commands/lib), backend dto.
2026-08-06 10:44:15 +02:00

179 lines
5.9 KiB
Rust

//! Filesystem chat attachment store.
//!
//! Project-scoped agent/chat attachments live under
//! `<root>/.ideai/attachments/agent-chat/<session-id>/`.
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<ChatAttachment, ChatAttachmentStoreError> {
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<ChatAttachment, ChatAttachmentStoreError> {
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<Vec<ChatAttachment>, 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)
}
}