//! 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) -> Result { 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), }