Skip to main content

task_graph_mcp/db/
attachments.rs

1//! Attachment storage operations.
2
3use super::{Database, now_ms};
4use crate::types::{Attachment, AttachmentMeta};
5use anyhow::{Result, anyhow};
6use rusqlite::params;
7
8impl Database {
9    /// Add an attachment to a task with auto-increment order_index.
10    /// Returns the order_index of the new attachment.
11    /// If file_path is provided, content should be empty (stored externally).
12    pub fn add_attachment(
13        &self,
14        task_id: &str,
15        name: String,
16        content: String,
17        mime_type: Option<String>,
18        file_path: Option<String>,
19    ) -> Result<i32> {
20        let now = now_ms();
21        let mime_type = mime_type.unwrap_or_else(|| "text/plain".to_string());
22
23        self.with_conn_mut(|conn| {
24            let tx = conn.transaction()?;
25
26            // Verify task exists
27            let exists: bool = tx
28                .query_row(
29                    "SELECT 1 FROM tasks WHERE id = ?1",
30                    params![task_id],
31                    |_| Ok(true),
32                )
33                .unwrap_or(false);
34
35            if !exists {
36                return Err(anyhow!("Task not found"));
37            }
38
39            // Get next order_index for this task
40            let max_order: Option<i32> = tx.query_row(
41                "SELECT MAX(order_index) FROM attachments WHERE task_id = ?1",
42                params![task_id],
43                |row| row.get(0),
44            )?;
45            let order_index = max_order.unwrap_or(-1) + 1;
46
47            tx.execute(
48                "INSERT INTO attachments (task_id, order_index, name, mime_type, content, file_path, created_at)
49                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
50                params![
51                    task_id,
52                    order_index,
53                    name,
54                    mime_type,
55                    content,
56                    file_path,
57                    now,
58                ],
59            )?;
60
61            tx.commit()?;
62            Ok(order_index)
63        })
64    }
65
66    /// Get attachments for a task, optionally including content.
67    /// Note: For file-based attachments, content is NOT loaded here - use get_attachment for that.
68    pub fn get_attachments_full(
69        &self,
70        task_id: &str,
71        include_content: bool,
72    ) -> Result<Vec<Attachment>> {
73        self.with_conn(|conn| {
74            let mut stmt = conn.prepare(
75                "SELECT task_id, order_index, name, mime_type, content, file_path, created_at
76                 FROM attachments WHERE task_id = ?1 ORDER BY order_index, created_at",
77            )?;
78
79            let attachments = stmt
80                .query_map(params![task_id], |row| {
81                    let task_id: String = row.get(0)?;
82                    let order_index: i32 = row.get(1)?;
83                    let name: String = row.get(2)?;
84                    let mime_type: String = row.get(3)?;
85                    let content: String = row.get(4)?;
86                    let file_path: Option<String> = row.get(5)?;
87                    let created_at: i64 = row.get(6)?;
88
89                    Ok(Attachment {
90                        task_id,
91                        order_index,
92                        name,
93                        mime_type,
94                        content: if include_content {
95                            content
96                        } else {
97                            String::new()
98                        },
99                        file_path,
100                        created_at,
101                    })
102                })?
103                .filter_map(|r| r.ok())
104                .collect();
105
106            Ok(attachments)
107        })
108    }
109
110    /// Get attachments for a task (metadata only).
111    pub fn get_attachments(&self, task_id: &str) -> Result<Vec<AttachmentMeta>> {
112        self.get_attachments_filtered(task_id, None, None)
113    }
114
115    /// Get attachments for a task with optional filtering (metadata only).
116    /// - name_pattern: Optional glob pattern (with * wildcard) to filter by attachment name
117    /// - mime_pattern: Optional prefix to filter by MIME type (e.g., "image/" matches "image/png")
118    pub fn get_attachments_filtered(
119        &self,
120        task_id: &str,
121        name_pattern: Option<&str>,
122        mime_pattern: Option<&str>,
123    ) -> Result<Vec<AttachmentMeta>> {
124        self.with_conn(|conn| {
125            // Build query with optional filters
126            let mut sql = String::from(
127                "SELECT task_id, order_index, name, mime_type, file_path, created_at
128                 FROM attachments WHERE task_id = ?1",
129            );
130
131            // For name pattern, convert glob to SQL LIKE pattern
132            let name_like = name_pattern.map(|p| {
133                // Convert glob wildcards to SQL LIKE: * -> %, ? -> _
134                p.replace('*', "%").replace('?', "_")
135            });
136
137            if name_like.is_some() {
138                sql.push_str(" AND name LIKE ?2 ESCAPE '\\'");
139            }
140
141            if mime_pattern.is_some() {
142                let idx = if name_like.is_some() { 3 } else { 2 };
143                sql.push_str(&format!(" AND mime_type LIKE ?{} ESCAPE '\\'", idx));
144            }
145
146            sql.push_str(" ORDER BY order_index, created_at");
147
148            let mut stmt = conn.prepare(&sql)?;
149
150            // Bind parameters based on which filters are present
151            let attachments: Vec<AttachmentMeta> = match (&name_like, mime_pattern) {
152                (Some(name), Some(mime)) => {
153                    let mime_like = format!("{}%", mime);
154                    stmt.query_map(params![task_id, name, mime_like], |row| {
155                        Self::map_attachment_meta(row)
156                    })?
157                    .filter_map(|r| r.ok())
158                    .collect()
159                }
160                (Some(name), None) => stmt
161                    .query_map(params![task_id, name], Self::map_attachment_meta)?
162                    .filter_map(|r| r.ok())
163                    .collect(),
164                (None, Some(mime)) => {
165                    let mime_like = format!("{}%", mime);
166                    stmt.query_map(params![task_id, mime_like], |row| {
167                        Self::map_attachment_meta(row)
168                    })?
169                    .filter_map(|r| r.ok())
170                    .collect()
171                }
172                (None, None) => stmt
173                    .query_map(params![task_id], Self::map_attachment_meta)?
174                    .filter_map(|r| r.ok())
175                    .collect(),
176            };
177
178            Ok(attachments)
179        })
180    }
181
182    /// Helper to map a row to AttachmentMeta.
183    fn map_attachment_meta(row: &rusqlite::Row) -> rusqlite::Result<AttachmentMeta> {
184        Ok(AttachmentMeta {
185            task_id: row.get(0)?,
186            order_index: row.get(1)?,
187            name: row.get(2)?,
188            mime_type: row.get(3)?,
189            file_path: row.get(4)?,
190            created_at: row.get(5)?,
191        })
192    }
193
194    /// Get a full attachment by (task_id, order_index).
195    /// Note: For file-based attachments, content field contains the DB content (empty).
196    /// The caller should read from file_path if set.
197    pub fn get_attachment(&self, task_id: &str, order_index: i32) -> Result<Option<Attachment>> {
198        self.with_conn(|conn| {
199            let mut stmt = conn.prepare(
200                "SELECT task_id, order_index, name, mime_type, content, file_path, created_at
201                 FROM attachments WHERE task_id = ?1 AND order_index = ?2",
202            )?;
203
204            let result = stmt.query_row(params![task_id, order_index], |row| {
205                let task_id: String = row.get(0)?;
206                let order_index: i32 = row.get(1)?;
207                let name: String = row.get(2)?;
208                let mime_type: String = row.get(3)?;
209                let content: String = row.get(4)?;
210                let file_path: Option<String> = row.get(5)?;
211                let created_at: i64 = row.get(6)?;
212
213                Ok(Attachment {
214                    task_id,
215                    order_index,
216                    name,
217                    mime_type,
218                    content,
219                    file_path,
220                    created_at,
221                })
222            });
223
224            match result {
225                Ok(attachment) => Ok(Some(attachment)),
226                Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
227                Err(e) => Err(e.into()),
228            }
229        })
230    }
231
232    /// Get just the file_path for an attachment (useful before deletion).
233    pub fn get_attachment_file_path(
234        &self,
235        task_id: &str,
236        order_index: i32,
237    ) -> Result<Option<String>> {
238        self.with_conn(|conn| {
239            let result = conn.query_row(
240                "SELECT file_path FROM attachments WHERE task_id = ?1 AND order_index = ?2",
241                params![task_id, order_index],
242                |row| row.get::<_, Option<String>>(0),
243            );
244
245            match result {
246                Ok(file_path) => Ok(file_path),
247                Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
248                Err(e) => Err(e.into()),
249            }
250        })
251    }
252
253    /// Delete an attachment by (task_id, order_index).
254    pub fn delete_attachment(&self, task_id: &str, order_index: i32) -> Result<bool> {
255        self.with_conn(|conn| {
256            let deleted = conn.execute(
257                "DELETE FROM attachments WHERE task_id = ?1 AND order_index = ?2",
258                params![task_id, order_index],
259            )?;
260
261            Ok(deleted > 0)
262        })
263    }
264
265    /// Delete an attachment by name (for replace behavior).
266    /// Returns the file_path if one was set (for cleanup).
267    pub fn delete_attachment_by_name(&self, task_id: &str, name: &str) -> Result<Option<String>> {
268        self.with_conn(|conn| {
269            // First get the file_path if any
270            let file_path: Option<String> = conn
271                .query_row(
272                    "SELECT file_path FROM attachments WHERE task_id = ?1 AND name = ?2",
273                    params![task_id, name],
274                    |row| row.get(0),
275                )
276                .ok()
277                .flatten();
278
279            // Delete the attachment
280            conn.execute(
281                "DELETE FROM attachments WHERE task_id = ?1 AND name = ?2",
282                params![task_id, name],
283            )?;
284
285            Ok(file_path)
286        })
287    }
288
289    /// Delete an attachment by name and return whether it was deleted plus the file_path.
290    /// Returns (was_deleted, file_path).
291    pub fn delete_attachment_by_name_ex(
292        &self,
293        task_id: &str,
294        name: &str,
295    ) -> Result<(bool, Option<String>)> {
296        self.with_conn(|conn| {
297            // First get the file_path if any
298            let file_path: Option<String> = conn
299                .query_row(
300                    "SELECT file_path FROM attachments WHERE task_id = ?1 AND name = ?2",
301                    params![task_id, name],
302                    |row| row.get(0),
303                )
304                .ok()
305                .flatten();
306
307            // Delete the attachment
308            let rows_affected = conn.execute(
309                "DELETE FROM attachments WHERE task_id = ?1 AND name = ?2",
310                params![task_id, name],
311            )?;
312
313            Ok((rows_affected > 0, file_path))
314        })
315    }
316}