Files
IdeA/crates/domain/src/chat_attachment.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

133 lines
4.1 KiB
Rust

//! Agent/chat attachment domain model.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, ProjectId, SessionId};
/// Stable chat attachment identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChatAttachmentId(String);
impl ChatAttachmentId {
/// Builds an attachment id.
///
/// # Errors
/// [`ChatAttachmentError::InvalidId`] when the id is empty or not filename-safe.
pub fn new(value: impl Into<String>) -> Result<Self, ChatAttachmentError> {
let value = value.into();
if value.is_empty()
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(ChatAttachmentError::InvalidId(value));
}
Ok(Self(value))
}
/// Returns the raw id.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Where the attachment originally came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChatAttachmentSourceKind {
/// User-selected local filesystem path.
LocalFile,
/// Clipboard paste, materialized by a driving adapter.
Clipboard,
/// Drag-and-drop, materialized by a driving adapter.
DragDrop,
/// Unknown or future source.
Other,
}
/// Durable metadata for one agent/chat attachment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatAttachment {
/// Stable attachment id.
pub id: ChatAttachmentId,
/// Project that owns the managed attachment.
pub project_id: ProjectId,
/// Target agent for the chat flow.
pub agent_id: AgentId,
/// Structured/chat session that imported the file.
pub session_id: SessionId,
/// Original display filename.
pub filename: String,
/// MIME type.
pub mime: String,
/// Size in bytes.
pub size_bytes: u64,
/// Origin kind.
pub source_kind: ChatAttachmentSourceKind,
/// Stored relative path under `.ideai/attachments`.
pub storage_path: String,
/// Absolute managed path readable by the target agent sandbox.
pub readable_path: String,
/// Creation time, epoch milliseconds.
pub created_at: u64,
}
impl ChatAttachment {
/// Validates persisted metadata.
///
/// # Errors
/// [`ChatAttachmentError`] when an invariant is violated.
pub fn validate(&self) -> Result<(), ChatAttachmentError> {
if self.filename.trim().is_empty()
|| self.filename.contains('/')
|| self.filename.contains('\\')
|| self.filename == "."
|| self.filename == ".."
{
return Err(ChatAttachmentError::InvalidFilename(self.filename.clone()));
}
if self.mime.trim().is_empty() {
return Err(ChatAttachmentError::InvalidMime(self.mime.clone()));
}
if self.storage_path.starts_with('/')
|| self.storage_path.starts_with('\\')
|| self.storage_path.contains("..")
|| !self.storage_path.starts_with("agent-chat/")
{
return Err(ChatAttachmentError::InvalidStoragePath(
self.storage_path.clone(),
));
}
if self.readable_path.trim().is_empty() {
return Err(ChatAttachmentError::InvalidReadablePath(
self.readable_path.clone(),
));
}
Ok(())
}
}
/// Attachment validation errors.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ChatAttachmentError {
/// Invalid id.
#[error("invalid chat attachment id: {0}")]
InvalidId(String),
/// Invalid filename.
#[error("invalid chat attachment filename: {0}")]
InvalidFilename(String),
/// Invalid MIME.
#[error("invalid chat attachment mime: {0}")]
InvalidMime(String),
/// Invalid relative storage path.
#[error("invalid chat attachment storage path: {0}")]
InvalidStoragePath(String),
/// Invalid readable path.
#[error("invalid chat attachment readable path: {0}")]
InvalidReadablePath(String),
}